Files
r3/handler/api/api.go
T
Gabriel Herbert 3bb30a4f02 API features/fixes
* Enabled use of PK attributes as lookups.
* Improved API call handling.
* API Builder improvements.
2023-03-02 23:06:00 +01:00

372 lines
9.6 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"r3/bruteforce"
"r3/cache"
"r3/config"
"r3/data"
"r3/data/data_import"
"r3/data/data_query"
"r3/db"
"r3/handler"
"r3/log"
"r3/login/login_auth"
"r3/types"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
func Handler(w http.ResponseWriter, r *http.Request) {
if blocked := bruteforce.Check(r); blocked {
handler.AbortRequestNoLog(w, handler.ErrBruteforceBlock)
return
}
w.Header().Set("Content-Type", "application/json")
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
var abort = func(httpCode int, errToLog error, errMsgUser string) {
// if not other error is prepared for log, use user error
if errToLog == nil {
errToLog = errors.New(errMsgUser)
}
handler.AbortRequestWithCode(w, "api", httpCode, errToLog, errMsgUser)
}
// check token
var loginId int64
var admin bool
var noAuth bool
if _, err := login_auth.Token(token, &loginId, &admin, &noAuth); err != nil {
abort(http.StatusUnauthorized, err, handler.ErrUnauthorized)
bruteforce.BadAttempt(r)
return
}
// check unreasonable URL length
// get language code
var languageCode string
if err := db.Pool.QueryRow(db.Ctx, `
SELECT language_code
FROM instance.login_setting
WHERE login_id = $1
`, loginId).Scan(&languageCode); err != nil {
abort(http.StatusServiceUnavailable, err, handler.ErrGeneral)
return
}
var isDelete, isGet, isPost bool
switch r.Method {
case "DELETE":
isDelete = true
case "GET":
isGet = true
case "POST":
isPost = true
default:
abort(http.StatusBadRequest, nil, "invalid HTTP method")
return
}
/*
Parse URL, such as:
GET /api/lsw_invoices/contracts/v1?limit=10
GET /api/lsw_invoices/contracts/v1/45
DELETE /api/lsw_invoices/contracts/v1/45
Rules:
Path must contain 5-6 elements (see examples above, split by '/')
6th element is the record ID, required by all except GET
GET can also have record ID (single record lookup)
*/
elements := strings.Split(r.URL.Path, "/")
recordIdProvided := len(elements) == 6
if len(elements) < 5 || len(elements) > 6 || (!isGet && !isPost && !recordIdProvided) {
examplePostfix := ""
if isGet || isPost {
examplePostfix = " (record ID is optional)"
}
abort(http.StatusBadRequest, nil, fmt.Sprintf("invalid URL, expected: /api/{APP_NAME}/{API_NAME}/{VERSION}/{RECORD_ID}%s", examplePostfix))
return
}
// process path elements
// 0 is empty, 1 = "api", 2 = MODULE_NAME, 3 = API_NAME, 4 = API_VERSION, 5 = RECORD_ID (some cases)
modName := elements[2]
apiName := elements[3]
version, err := strconv.ParseInt(elements[4][1:], 10, 64) // expected format: "v3"
if err != nil {
abort(http.StatusBadRequest, err, fmt.Sprintf("invalid API version format '%s', expected: 'v12'", elements[4]))
return
}
var recordId int64
if len(elements) == 6 {
recordId, err = strconv.ParseInt(elements[5], 10, 64)
if err != nil {
abort(http.StatusBadRequest, err, fmt.Sprintf("invalid API record ID '%s', integer expected", elements[5]))
return
}
}
// URL processing complete, actually use API
log.Info("api", fmt.Sprintf("%s.%s (v%d) is called with %s (record ID: %d)",
modName, apiName, version, r.Method, recordId))
// resolve API by module+API names
cache.Schema_mx.RLock()
defer cache.Schema_mx.RUnlock()
apiId, exists := cache.ModuleApiNameMapId[modName][apiName]
if !exists {
abort(http.StatusNotFound, nil, fmt.Sprintf("API '%s.%s' not found", modName, apiName))
return
}
api := cache.ApiIdMap[apiId]
// check supported API methods
if (isDelete && !api.HasDelete) ||
(isGet && !api.HasGet) ||
(isPost && !api.HasPost) {
abort(http.StatusBadRequest, nil, fmt.Sprintf("HTTP method '%s' is not supported by this API", r.Method))
return
}
if !api.Query.RelationId.Valid {
abort(http.StatusServiceUnavailable, nil, "query has no base relation")
return
}
// TEMP
// check role access
// parse general getters
var getters struct {
limit int
offset int
verbose bool
}
getters.limit = api.LimitDef
getters.verbose = api.VerboseDef
for getter, value := range r.URL.Query() {
if len(value) == 1 && getter == "limit" || getter == "offset" || getter == "verbose" {
n, err := strconv.Atoi(value[0])
if err != nil {
abort(http.StatusBadRequest, err, fmt.Sprintf("invalid value '%s' for %s", value[0], getter))
return
}
switch getter {
case "limit":
getters.limit = n
case "offset":
getters.offset = n
case "verbose":
getters.verbose = n == 1
}
}
}
// execute request
ctx, ctxCancel := context.WithTimeout(context.Background(),
time.Duration(int64(config.GetUint64("dbTimeoutDataRest")))*time.Second)
defer ctxCancel()
tx, err := db.Pool.Begin(ctx)
if err != nil {
abort(http.StatusServiceUnavailable, err, handler.ErrGeneral)
return
}
defer tx.Rollback(ctx)
if isDelete {
}
if isGet {
dataGet := types.DataGet{
RelationId: api.Query.RelationId.Bytes,
IndexSource: 0,
Limit: getters.limit,
Offset: getters.offset,
}
// abort if requested limit exceeds max limit
// better to abort as smaller than requested result count might suggest the absence of more data
if api.LimitMax < dataGet.Limit {
abort(http.StatusBadRequest, nil, fmt.Sprintf("max. result limit is: %d", api.LimitMax))
return
}
for _, join := range api.Query.Joins {
if join.Index == 0 {
continue
}
dataGet.Joins = append(dataGet.Joins, types.DataGetJoin{
AttributeId: join.AttributeId.Bytes,
Index: join.Index,
IndexFrom: join.IndexFrom,
Connector: join.Connector,
})
}
for _, column := range api.Columns {
atrId := pgtype.UUID{
Bytes: column.AttributeId,
Valid: true,
}
expr := types.DataGetExpression{
AttributeId: atrId,
Index: column.Index,
}
if column.SubQuery {
expr.Query = data_query.ConvertSubQueryToDataGet(column.Query,
column.Aggregator, atrId, column.Index, loginId, languageCode)
}
dataGet.Expressions = append(dataGet.Expressions, expr)
}
// apply query filters
dataGet.Filters = data_query.ConvertQueryToDataFilter(
api.Query.Filters, loginId, languageCode)
// add record filter
if recordId != 0 {
dataGet.Filters = append(dataGet.Filters, types.DataGetFilter{
Connector: "AND",
Operator: "=",
Side0: types.DataGetFilterSide{
AttributeId: pgtype.UUID{
Bytes: cache.RelationIdMap[api.Query.RelationId.Bytes].AttributeIdPk,
Valid: true,
},
},
Side1: types.DataGetFilterSide{Value: recordId},
})
}
// apply query sorting
dataGet.Orders = data_query.ConvertQueryToDataOrders(api.Query.Orders)
// get data
var query string
results, _, err := data.Get_tx(ctx, tx, dataGet, loginId, &query)
if err != nil {
if err.Error() == handler.ErrUnauthorized {
abort(http.StatusUnauthorized, err, handler.ErrUnauthorized)
return
}
abort(http.StatusServiceUnavailable, err, handler.ErrGeneral)
return
}
// parse output
rows := make([]interface{}, 0)
if !getters.verbose {
for _, result := range results {
rows = append(rows, result.Values)
}
} else {
// resolve attribute names
atrNames := make([]string, len(api.Columns))
for i, column := range api.Columns {
atrNames[i] = cache.AttributeIdMap[column.AttributeId].Name
if column.Aggregator.Valid {
atrNames[i] = fmt.Sprintf("%s (%s)",
strings.ToUpper(column.Aggregator.String), atrNames[i])
}
}
for _, result := range results {
row := make(map[string]interface{})
for i, value := range result.Values {
row[atrNames[i]] = value
}
rows = append(rows, row)
}
}
payloadJson, err := json.Marshal(rows)
if err != nil {
abort(http.StatusServiceUnavailable, err, handler.ErrGeneral)
return
}
w.Write(payloadJson)
}
if isPost {
values := make([]interface{}, len(api.Columns))
if !getters.verbose {
// non-verbose mode: values are following columns (equal count and order)
// [123,"Fritz","Hans"]
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
abort(http.StatusBadRequest, err, "invalid JSON object")
return
}
} else {
// verbose mode: relation index -> attribute name -> value
// convert verbose to non-verbose input (to process both inputs the same way)
/*{
"0":{ "attribute0a":123, "attribute0b":"Fritz" },
"1":{ "attribute1a":"Hans" }
}*/
var jsonObj map[string]map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&jsonObj); err != nil {
abort(http.StatusBadRequest, err, "invalid JSON object")
return
}
// pre-populate values with nil, in case required attribute values are not given
for i, _ := range api.Columns {
values[i] = nil
}
for relIndexStr, attributeNameMapValues := range jsonObj {
relIndex, err := strconv.Atoi(relIndexStr)
if err != nil {
abort(http.StatusBadRequest, nil, fmt.Sprintf("invalid relation index '%s', integer expected", relIndexStr))
return
}
for i, column := range api.Columns {
if column.Index == relIndex {
atr := cache.AttributeIdMap[column.AttributeId]
if value, exists := attributeNameMapValues[atr.Name]; exists {
values[i] = value
}
}
}
}
}
if err := data_import.FromInterfaceValues_tx(ctx, tx, loginId, values,
api.Columns, api.Query.Joins, api.Query.Lookups); err != nil {
abort(http.StatusServiceUnavailable, err, handler.ErrGeneral)
return
}
w.WriteHeader(http.StatusOK)
}
// apply changes
if err := tx.Commit(ctx); err != nil {
abort(http.StatusServiceUnavailable, err, handler.ErrGeneral)
return
}
}