mirror of
https://github.com/r3-team/r3.git
synced 2026-05-12 20:29:16 -05:00
3bb30a4f02
* Enabled use of PK attributes as lookups. * Improved API call handling. * API Builder improvements.
372 lines
9.6 KiB
Go
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
|
|
}
|
|
}
|