Skip to content

Commit

Permalink
API DELETE call, fixes, UI improvements
Browse files Browse the repository at this point in the history
* API name check.
* DELETE call handling.
* Missing version property for new APIs.
* Error messages not properly escaped.
  • Loading branch information
r3-gabriel committed Mar 5, 2023
1 parent ee002bd commit 9228385
Show file tree
Hide file tree
Showing 13 changed files with 132 additions and 33 deletions.
8 changes: 3 additions & 5 deletions data/data_del.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,12 @@ func Del_tx(ctx context.Context, tx pgx.Tx, relationId uuid.UUID,
return err
}

if _, err := tx.Exec(ctx, fmt.Sprintf(`
_, err = tx.Exec(ctx, fmt.Sprintf(`
DELETE FROM "%s"."%s" AS "%s"
WHERE "%s"."%s" = $1
%s
`, mod.Name, rel.Name, tableAlias, tableAlias,
schema.PkName, policyFilter), recordId); err != nil {
schema.PkName, policyFilter), recordId)

return err
}
return nil
return err
}
9 changes: 5 additions & 4 deletions data/data_import/data_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,15 +185,16 @@ func FromInterfaceValues_tx(ctx context.Context, tx pgx.Tx, loginId int64,

namesWhere := make([]string, 0)
for i, name := range names {
namesWhere = append(namesWhere, fmt.Sprintf("%s = $%d", name, (i+1)))
namesWhere = append(namesWhere, fmt.Sprintf(`"%s" = $%d`, name, (i+1)))
}

var recordId int64
err := tx.QueryRow(ctx, fmt.Sprintf(`
SELECT id
FROM %s.%s
SELECT %s
FROM "%s"."%s"
WHERE %s
`, mod.Name, rel.Name, strings.Join(namesWhere, "\nAND ")), paras...).Scan(&recordId)
`, schema.PkName, mod.Name, rel.Name,
strings.Join(namesWhere, "\nAND ")), paras...).Scan(&recordId)

if err == pgx.ErrNoRows {
indexesResolved = append(indexesResolved, join.Index)
Expand Down
88 changes: 82 additions & 6 deletions handler/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"r3/handler"
"r3/log"
"r3/login/login_auth"
"r3/schema"
"r3/types"
"regexp"
"strconv"
Expand Down Expand Up @@ -94,10 +95,10 @@ func Handler(w http.ResponseWriter, r *http.Request) {
elements := strings.Split(r.URL.Path, "/")
recordIdProvided := len(elements) == 6

if len(elements) < 5 || len(elements) > 6 || (!isGet && !isPost && !recordIdProvided) {
if len(elements) < 5 || len(elements) > 6 || (isDelete && !recordIdProvided) {

examplePostfix := ""
if isGet || isPost {
if isGet {
examplePostfix = " (record ID is optional)"
}
abort(http.StatusBadRequest, nil, fmt.Sprintf("invalid URL, expected: /api/{APP_NAME}/{API_NAME}/{VERSION}/{RECORD_ID}%s", examplePostfix))
Expand All @@ -124,7 +125,7 @@ func Handler(w http.ResponseWriter, r *http.Request) {
}

// URL processing complete, actually use API
log.Info("api", fmt.Sprintf("%s.%s (v%d) is called with %s (record ID: %d)",
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
Expand All @@ -133,7 +134,7 @@ func Handler(w http.ResponseWriter, r *http.Request) {

apiId, exists := cache.ModuleApiNameMapId[modName][fmt.Sprintf("%s.v%d", apiName, version)]
if !exists {
abort(http.StatusNotFound, nil, fmt.Sprintf("API '%s.%s' not found", modName, apiName))
abort(http.StatusNotFound, nil, fmt.Sprintf("API '%s.%s' (v%d) does not exist", modName, apiName, version))
return
}
api := cache.ApiIdMap[apiId]
Expand Down Expand Up @@ -195,7 +196,82 @@ func Handler(w http.ResponseWriter, r *http.Request) {
defer tx.Rollback(ctx)

if isDelete {
if recordId < 1 {
abort(http.StatusBadRequest, nil, "record ID must be > 0")
return
}

// look up all records from joined relations
// continue even if some joins do not have DELETE enabled, as its necessary for later joins that might require a DELETE
// joins are ordered smaller indexes first, later joined relations always have higher indexes than their partners
relationIndexMapRecordIds := make(map[int][]int64)
for _, join := range api.Query.Joins {
if join.Index == 0 {
relationIndexMapRecordIds[0] = []int64{recordId}
continue
}

if _, exists := relationIndexMapRecordIds[join.IndexFrom]; !exists {
// no record on the partner relation, skip
continue
}

ids := make([]int64, 0)
joinAtr, exists := cache.AttributeIdMap[join.AttributeId.Bytes]
if !exists {
abort(http.StatusServiceUnavailable, nil,
handler.ErrSchemaUnknownAttribute(join.AttributeId.Bytes).Error())

return
}

var atrNameLookup, atrNameFilter string
var rel types.Relation

if joinAtr.RelationId == join.RelationId {
atrNameLookup = schema.PkName
atrNameFilter = joinAtr.Name
rel = cache.RelationIdMap[join.RelationId]
} else {
// join from other relation
atrNameLookup = joinAtr.Name
atrNameFilter = schema.PkName
rel = cache.RelationIdMap[joinAtr.RelationId]
}
mod := cache.ModuleIdMap[rel.ModuleId]

if err := tx.QueryRow(ctx, fmt.Sprintf(`
SELECT ARRAY(
SELECT "%s"
FROM "%s"."%s"
WHERE "%s" = ANY($1)
)
`, atrNameLookup, mod.Name, rel.Name, atrNameFilter),
relationIndexMapRecordIds[join.IndexFrom]).Scan(&ids); err != nil {

abort(http.StatusServiceUnavailable, err, handler.ErrGeneral)
return
}
relationIndexMapRecordIds[join.Index] = ids
}

// execute delete
for _, join := range api.Query.Joins {

if _, exists := relationIndexMapRecordIds[join.Index]; !exists {
continue
}
if !join.ApplyDelete || len(relationIndexMapRecordIds[join.Index]) == 0 {
continue
}

for _, id := range relationIndexMapRecordIds[join.Index] {
if err := data.Del_tx(ctx, tx, join.RelationId, id, loginId); err != nil {
abort(http.StatusConflict, nil, err.Error())
return
}
}
}
}

if isGet {
Expand Down Expand Up @@ -270,7 +346,7 @@ func Handler(w http.ResponseWriter, r *http.Request) {
abort(http.StatusUnauthorized, err, handler.ErrUnauthorized)
return
}
abort(http.StatusServiceUnavailable, err, handler.ErrGeneral)
abort(http.StatusServiceUnavailable, nil, err.Error())
return
}

Expand Down Expand Up @@ -380,7 +456,7 @@ func Handler(w http.ResponseWriter, r *http.Request) {
api.Columns, api.Query.Joins, api.Query.Lookups)

if err != nil {
abort(http.StatusServiceUnavailable, nil, err.Error())
abort(http.StatusConflict, nil, err.Error())
return
}

Expand Down
15 changes: 13 additions & 2 deletions handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handler

import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
Expand Down Expand Up @@ -63,11 +64,21 @@ func AbortRequestWithCode(w http.ResponseWriter, context string,

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpCode)
w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, errMessageUser)))

json, _ := json.Marshal(struct {
Error string `json:"error"`
}{Error: errMessageUser})

w.Write(json)
}

func AbortRequestNoLog(w http.ResponseWriter, errMessageUser string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, errMessageUser)))

json, _ := json.Marshal(struct {
Error string `json:"error"`
}{Error: errMessageUser})

w.Write(json)
}
5 changes: 5 additions & 0 deletions schema/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"r3/db"
"r3/db/check"
"r3/schema"
"r3/schema/column"
"r3/schema/query"
Expand Down Expand Up @@ -60,6 +61,10 @@ func Get(moduleId uuid.UUID) ([]types.Api, error) {

func Set_tx(tx pgx.Tx, api types.Api) error {

if err := check.DbIdentifier(api.Name); err != nil {
return err
}

known, err := schema.CheckCreateId_tx(tx, &api.Id, "api", "id")
if err != nil {
return err
Expand Down
3 changes: 3 additions & 0 deletions www/comps/builder/builder.css
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,9 @@
.builder-api .code-preview.high{
height:500px;
}
.builder-api .code-preview.low{
height:40px;
}
.builder-api-columns{
display:flex;
flex-flow:column nowrap;
Expand Down
12 changes: 6 additions & 6 deletions www/comps/builder/builderApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,22 +114,22 @@ let MyBuilderApiPreview = {
<td colspan="2">
<div class="column gap">
<textarea class="long code-preview" disabled="disabled"
:class="{ high:isPost }"
:class="{ high:isPost, low:isGet || isDelete }"
:value="request"
></textarea>
<span v-if="isPost && !params.verbose">{{ capApp.requestHintPost }}</span>
<span v-if="isPost && !params.verbose" v-html="capApp.requestHintPost"></span>
</div>
</td>
</tr>
<tr v-if="!isDelete">
<tr>
<td>{{ capApp.response }}</td>
<td colspan="2">
<div class="column gap">
<textarea class="long code-preview" disabled="disabled"
:class="{ high:isGet }"
:class="{ high:isGet, low:isDelete }"
:value="response"
></textarea>
<span v-if="isPost">{{ capApp.responseHintPost }}</span>
<span v-if="isPost" v-html="capApp.responseHintPost"></span>
</div>
</td>
</tr>
Expand Down Expand Up @@ -186,7 +186,7 @@ let MyBuilderApiPreview = {
}
return JSON.stringify(out,null,'\t');
}
return '';
return s.capApp.empty;
},
url:(s) => {
let base = `https://${s.config.publicHostName}/api/`;
Expand Down
4 changes: 2 additions & 2 deletions www/comps/builder/builderApis.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@ let MyBuilderApis = {
if(api.hasGet) out.push('G');
if(api.hasPost) out.push('P');
if(api.hasDelete) out.push('D');
return out.join(' ');
return `[${out.join('')}]`;
},
captionTitle(api) {
let out = [];
if(api.hasGet) out.push('GET');
if(api.hasPost) out.push('POST');
if(api.hasDelete) out.push('DELETE');
return out.join(' ');
return out.join(', ');
}
}
};
3 changes: 2 additions & 1 deletion www/comps/builder/builderNew.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ let MyBuilderNew = {
hasPost:false,
limitDef:100,
limitMax:1000,
verboseDef:true
verboseDef:true,
version:1
};
break;
case 'collection':
Expand Down
9 changes: 5 additions & 4 deletions www/langs/REPLACE_BY_BUILD/de_de
Original file line number Diff line number Diff line change
Expand Up @@ -510,10 +510,10 @@
"delete":"Bist du sicher, dass du diese API löschen möchtest?"
},
"hint":{
"auth":"An authentication call is required to get a valid access token for further requests. The token is valid following the max. session time, set in the system configuration.",
"auth":"Ein Authentifizierungsaufruf ist erforderlich, um ein gültiges Zugangs-Token für weitere Anfragen zu erhalten. Das Token ist nach der in der Systemkonfiguration eingestellten maximalen Sitzungszeit gültig.",
"delete":"Löscht einen bestehenden Datensatz - plus zusammenhängende Datensätze, wenn andere Relationen verbunden sind. Relationen müssen die Option \"Löschen\" aktiv haben, um berücksichtigt zu werden.",
"get":"Liefert Werte von einem bestehenden Datensatz (wenn Datensatz-ID definiert ist) oder von allen verfügbaren Datensätzen von einer oder mehreren, verbundenen Relationen.",
"post":"Erstellt oder aktualisiert einen Datensatz - plus zusammenhängende Datensätze, wenn andere Relationen verbunden sind. Relationen müssen die Optionen \"Erzeugen\"/\"Aktualisieren\" aktiv haben, um berücksichtigt zu werden."
"post":"Erzeugt oder aktualisiert einen Datensatz - plus zusammenhängende Datensätze, wenn andere Relationen verbunden sind. Relationen müssen die Optionen \"Erzeugen\"/\"Aktualisieren\" aktiv haben, um berücksichtigt zu werden."
},
"preview":{
"call":"Aufruf",
Expand All @@ -529,7 +529,7 @@
"request":"Anfragebeispiel (Body)",
"requestHintPost":"Falls Verbose-Modus deaktiviert ist, muss die Wertereihenfolge der aktiven Spalten entsprechen.",
"response":"Antwortbeispiel (Body)",
"responseHintPost":"POST-Antworten liefern die Datensatz-IDs der betroffenen Relationen (nach Relation-Join-Index). Die Datensatz-ID ist eine bestehende (falls aktualisiert) oder neue (falls erzeugt).",
"responseHintPost":"POST-Antworten liefern die Datensatz-IDs der betroffenen Relationen (nach Relation-Join-Index). Die Datensatz-ID ist eine bestehende (falls aktualisiert) oder neue (falls erzeugt).<br /><br />Datensatzerkennung muss definiert sein (Tab \"Inhalt\"), damit Datensätze aktualisiert werden können.",
"verboseHint":"Wenn diese Option aktiviert ist, benutzen POST-Anfragen und GET-Ausgaben Entitätsnamen anstatt geordnete Wertelisten. Option aktivieren, um eine Vorschau weiter unten zu sehen."
},
"calls":"Aufrufe",
Expand All @@ -540,7 +540,7 @@
"contentValue":"REST",
"httpMethods":"HTTP-Methoden",
"limitDef":"GET Standard-Ergebnisanzahl",
"limitDefHint":"Anzahl der Ergebnisse wenn nicht überschrieben mit dem Parameter \"limit\" (bspw. \"limit=1000\").",
"limitDefHint":"Anzahl der Ergebnisse, wenn nicht überschrieben mit dem Parameter \"limit\" (bspw. \"limit=1000\").",
"limitMax":"GET maximale Ergebnisanzahl",
"limitMaxHint":"Höchste Ergebnisanzahl, die mit einem GET-Aufruf geliefert wird.",
"nameHint":"Der API-Name wird im REST-Aufruf verwendet. The Aufruf muss verändert werden, wenn der API-Name sich ändert.",
Expand Down Expand Up @@ -1041,6 +1041,7 @@
},
"new":{
"message":{
"api":"<p>Der API-Name wird als Teil des API-Aufrufs verwendet. Er kann später geändert werden.</p>",
"module":"<p>Bitte wählen Sie einen Namen für Ihre Anwendung. Dieser Name dient zur eindeutigen Identifizierung Ihrer Anwendung in einem REI3-System. Er ist nicht für Benutzer sichtbar.</p><h3>Beispiele:</h3><ul><li>shift_planner</li><li>inventory_flow</li><li>time_tracker</li></ul><p>Da der Anwendungsname eindeutig sein muss, kann ein nach Ihrer Organisation benannter Präfix sinnvoll sein. Falls Ihre Organisation bspw. \"Modern Fashion GmbH\" heißt, könnten Sie \"mfg\" als Präfix nutzen (also \"mfg_shift_planner\").</p>",
"relation":"<p>Ein Relationsname sollte darstellen, was in dieser gespeichert wird. Er muss in einer Anwendung einzigartig sein.</p><h3>Beispiele:</h3><ul><li>event</li><li>event_attendance</li><li>location</li><li>location_event</li></ul>"
},
Expand Down
3 changes: 2 additions & 1 deletion www/langs/REPLACE_BY_BUILD/en_us
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@
"request":"Request example (body)",
"requestHintPost":"If verbose mode is disabled, value order must be identical to active columns.",
"response":"Response example (body)",
"responseHintPost":"POST responses return the record IDs for each affected relation (by relation join index). The record ID will be an existing (if updated) or new one (if created).",
"responseHintPost":"POST responses return the record IDs for each affected relation (by relation join index). The record ID will be an existing (if updated) or new one (if created).<br /><br />Make sure to define record lookups (tab 'Content') if you want to update records.",
"verboseHint":"If enabled, POST requests and GET responses use entity names instead of ordered value lists. Enable this option to see a preview below."
},
"calls":"Calls",
Expand Down Expand Up @@ -1041,6 +1041,7 @@
},
"new":{
"message":{
"api":"<p>The API name will be used as part of the API call. It can be changed later.</p>",
"module":"<p>Please choose a name for your application. This name serves to uniquely identify your application inside a REI3 system. It is not visible to users.</p><h3>Examples:</h3><ul><li>shift_planner</li><li>inventory_flow</li><li>time_tracker</li></ul><p>Because application names must be unique, it can help to use a prefix named after your organization. If your organization is called 'Modern Fashion Limited', you could use 'mfl' as prefix (as in 'mfl_shift_planner').</p>",
"relation":"<p>A relation name should reflect what is stored inside it. It must be unique within an application.</p><h3>Examples:</h3><ul><li>event</li><li>event_attendance</li><li>location</li><li>location_event</li></ul>"
},
Expand Down
3 changes: 2 additions & 1 deletion www/langs/REPLACE_BY_BUILD/it_it
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@
"request":"Request example (body)",
"requestHintPost":"If verbose mode is disabled, value order must be identical to active columns.",
"response":"Response example (body)",
"responseHintPost":"POST responses return the record IDs for each affected relation (by relation join index). The record ID will be an existing (if updated) or new one (if created).",
"responseHintPost":"POST responses return the record IDs for each affected relation (by relation join index). The record ID will be an existing (if updated) or new one (if created).<br /><br />Make sure to define record lookups (tab 'Content') if you want to update records.",
"verboseHint":"If enabled, POST requests and GET responses use entity names instead of ordered value lists. Enable this option to see a preview below."
},
"calls":"Calls",
Expand Down Expand Up @@ -1041,6 +1041,7 @@
},
"new":{
"message":{
"api":"<p>The API name will be used as part of the API call. It can be changed later.</p>",
"module":"<p>Please choose a name for your application. This name serves to uniquely identify your application inside a REI3 system. It is not visible to users.</p><h3>Examples:</h3><ul><li>shift_planner</li><li>inventory_flow</li><li>time_tracker</li></ul><p>Because application names must be unique, it can help to use a prefix named after your organization. If your organization is called 'Modern Fashion Limited', you could use 'mfl' as prefix (as in 'mfl_shift_planner').</p>",
"relation":"<p>A relation name should reflect what is stored inside it. It must be unique within an application.</p><h3>Examples:</h3><ul><li>event</li><li>event_attendance</li><li>location</li><li>location_event</li></ul>"
},
Expand Down
3 changes: 2 additions & 1 deletion www/langs/REPLACE_BY_BUILD/ro_ro
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@
"request":"Request example (body)",
"requestHintPost":"If verbose mode is disabled, value order must be identical to active columns.",
"response":"Response example (body)",
"responseHintPost":"POST responses return the record IDs for each affected relation (by relation join index). The record ID will be an existing (if updated) or new one (if created).",
"responseHintPost":"POST responses return the record IDs for each affected relation (by relation join index). The record ID will be an existing (if updated) or new one (if created).<br /><br />Make sure to define record lookups (tab 'Content') if you want to update records.",
"verboseHint":"If enabled, POST requests and GET responses use entity names instead of ordered value lists. Enable this option to see a preview below."
},
"calls":"Calls",
Expand Down Expand Up @@ -1041,6 +1041,7 @@
},
"new":{
"message":{
"api":"<p>The API name will be used as part of the API call. It can be changed later.</p>",
"module":"<p>Please choose a name for your application. This name serves to uniquely identify your application inside a REI3 system. It is not visible to users.</p><h3>Examples:</h3><ul><li>shift_planner</li><li>inventory_flow</li><li>time_tracker</li></ul><p>Because application names must be unique, it can help to use a prefix named after your organization. If your organization is called 'Modern Fashion Limited', you could use 'mfl' as prefix (as in 'mfl_shift_planner').</p>",
"relation":"<p>A relation name should reflect what is stored inside it. It must be unique within an application.</p><h3>Examples:</h3><ul><li>event</li><li>event_attendance</li><li>location</li><li>location_event</li></ul>"
},
Expand Down

0 comments on commit 9228385

Please sign in to comment.