376 lines
9.0 KiB
Go
376 lines
9.0 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"git.golem15.com/golem15/summercms/modules/wire"
|
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// The record routes of the management API: courses, meetings, class
|
|
// sessions, readings and deadlines, one by one, so a script changes a row
|
|
// without a seed run. A body is a JSON object keyed by column names; only the
|
|
// model's Fillable() columns are accepted (anything else is a 422), the
|
|
// model's Rules() are checked as in the admin, against the row as it would
|
|
// be saved, and so is TemporalErrors() where a model has it. A record is
|
|
// answered as an object of its columns, times as RFC 3339 in Europe/Warsaw
|
|
// and dates as YYYY-MM-DD.
|
|
|
|
// recordModel is what a managed model provides.
|
|
type recordModel interface {
|
|
TableName() string
|
|
Fillable() []string
|
|
Rules() map[string]string
|
|
}
|
|
|
|
type temporalChecker interface {
|
|
TemporalErrors() map[string][]string
|
|
}
|
|
|
|
// Record describes one managed table.
|
|
type Record[T any, PT interface {
|
|
*T
|
|
recordModel
|
|
}] struct {
|
|
// Filters are the columns a list may be filtered on (?column=value).
|
|
Filters []string
|
|
// Order is the ORDER BY of a list.
|
|
Order string
|
|
// References maps a foreign key column to the table it must exist in.
|
|
References map[string]string
|
|
}
|
|
|
|
func (rec Record[T, PT]) table() string { return PT(new(T)).TableName() }
|
|
|
|
// List answers GET /{records}: every row, filtered by the query parameters
|
|
// named in Filters.
|
|
func (rec Record[T, PT]) List(h *Handlers) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
q := db.Model(new(T))
|
|
for _, col := range rec.Filters {
|
|
v := r.URL.Query().Get(col)
|
|
switch {
|
|
case v == "":
|
|
continue
|
|
case v == "null":
|
|
q = q.Where(col + " IS NULL")
|
|
default:
|
|
q = q.Where(col+" = ?", v)
|
|
}
|
|
}
|
|
var rows []T
|
|
if err := q.Order(rec.Order).Find(&rows).Error; err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, recordJSON(&rows[i]))
|
|
}
|
|
writeData(w, out)
|
|
}
|
|
}
|
|
|
|
// Show answers GET /{records}/{id}.
|
|
func (rec Record[T, PT]) Show(h *Handlers) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
row, ok := rec.find(w, r, db)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeData(w, recordJSON(row))
|
|
}
|
|
}
|
|
|
|
// Create answers POST /{records}: 201 with the new row.
|
|
func (rec Record[T, PT]) Create(h *Handlers) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
rec.save(w, r, h, db, new(T), true)
|
|
}
|
|
}
|
|
|
|
// Update answers PATCH /{records}/{id}: the sent columns change, the rest
|
|
// stay.
|
|
func (rec Record[T, PT]) Update(h *Handlers) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
row, ok := rec.find(w, r, db)
|
|
if !ok {
|
|
return
|
|
}
|
|
rec.save(w, r, h, db, row, false)
|
|
}
|
|
}
|
|
|
|
// Delete answers DELETE /{records}/{id}: 204. Dependent rows follow the
|
|
// foreign keys (reading progress goes with its reading).
|
|
func (rec Record[T, PT]) Delete(h *Handlers) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
db, ok := h.db(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := surf.IntParam(r, "id")
|
|
if !ok {
|
|
writeNotFound(w)
|
|
return
|
|
}
|
|
res := db.Where("id = ?", id).Delete(new(T))
|
|
if res.Error != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
writeNotFound(w)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
func (rec Record[T, PT]) find(w http.ResponseWriter, r *http.Request, db *gorm.DB) (*T, bool) {
|
|
id, ok := surf.IntParam(r, "id")
|
|
if !ok {
|
|
writeNotFound(w)
|
|
return nil, false
|
|
}
|
|
row := new(T)
|
|
res := db.Where("id = ?", id).Limit(1).Find(row)
|
|
if res.Error != nil {
|
|
writeOpaque500(w)
|
|
return nil, false
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
writeNotFound(w)
|
|
return nil, false
|
|
}
|
|
return row, true
|
|
}
|
|
|
|
// save decodes the body onto row, validates the result and writes it.
|
|
func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handlers, db *gorm.DB, row *T, created bool) {
|
|
raw, ok := body(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var in map[string]any
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&in); err != nil || in == nil {
|
|
writeInvalid(w, "The body must be a JSON object of columns.")
|
|
return
|
|
}
|
|
model := PT(row)
|
|
fillable := model.Fillable()
|
|
errs := map[string][]string{}
|
|
for key := range in {
|
|
if !slices.Contains(fillable, key) {
|
|
errs[key] = []string{"Unknown or read-only field; allowed: " + strings.Join(fillable, ", ") + "."}
|
|
}
|
|
}
|
|
if len(errs) > 0 {
|
|
writeValidation(w, errs)
|
|
return
|
|
}
|
|
values, fieldErrs := normalizeValues(row, in)
|
|
for k, v := range fieldErrs {
|
|
errs[k] = v
|
|
}
|
|
if len(errs) == 0 {
|
|
var typeErr *lagoon.FillTypeError
|
|
if err := lagoon.Fill(row, fillable, values, true); errors.As(err, &typeErr) {
|
|
errs[typeErr.Key] = []string{"The " + typeErr.Key + " has the wrong type."}
|
|
} else if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
}
|
|
if len(errs) > 0 {
|
|
writeValidation(w, errs)
|
|
return
|
|
}
|
|
// The rules run on the row as it would be saved, so a PATCH of one
|
|
// column still meets the required ones.
|
|
check := recordJSON(row)
|
|
verrs, err := lagoon.Validate(r.Context(), db, row, model.Rules(), check, h.translator())
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
for k, v := range verrs {
|
|
errs[k] = v
|
|
}
|
|
if tc, ok := any(row).(temporalChecker); ok {
|
|
for k, v := range tc.TemporalErrors() {
|
|
errs[k] = append(errs[k], v...)
|
|
}
|
|
}
|
|
for col, table := range rec.References {
|
|
id, ok := check[col].(uint)
|
|
if !ok || id == 0 {
|
|
continue
|
|
}
|
|
var n int64
|
|
if err := db.Table(table).Where("id = ?", id).Count(&n).Error; err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
if n == 0 {
|
|
errs[col] = append(errs[col], "The selected "+col+" is invalid.")
|
|
}
|
|
}
|
|
if len(errs) > 0 {
|
|
writeValidation(w, errs)
|
|
return
|
|
}
|
|
err = lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
|
|
return tx.Save(row).Error
|
|
})
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
status := http.StatusOK
|
|
if created {
|
|
status = http.StatusCreated
|
|
}
|
|
wire.WriteJSON(w, status, map[string]any{"data": recordJSON(row)})
|
|
}
|
|
|
|
var (
|
|
timeType = reflect.TypeOf(time.Time{})
|
|
dateType = reflect.TypeOf(lagoon.Date{})
|
|
)
|
|
|
|
// columns maps the column names of a model to their struct field indexes.
|
|
func columns(t reflect.Type) map[string]int {
|
|
out := map[string]int{}
|
|
for i := 0; i < t.NumField(); i++ {
|
|
for _, part := range strings.Split(t.Field(i).Tag.Get("gorm"), ";") {
|
|
if name, ok := strings.CutPrefix(part, "column:"); ok {
|
|
out[name] = i
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalizeValues turns the decoded JSON into what lagoon.Fill takes:
|
|
// numbers as int64 or float64, times parsed in Europe/Warsaw (RFC 3339 or
|
|
// "2006-01-02 15:04"), dates as YYYY-MM-DD, and "" as null for optional
|
|
// text.
|
|
func normalizeValues(row any, in map[string]any) (map[string]any, map[string][]string) {
|
|
t := reflect.TypeOf(row).Elem()
|
|
cols := columns(t)
|
|
out := make(map[string]any, len(in))
|
|
errs := map[string][]string{}
|
|
for key, v := range in {
|
|
ft := t.Field(cols[key]).Type
|
|
base := ft
|
|
if base.Kind() == reflect.Pointer {
|
|
base = base.Elem()
|
|
}
|
|
switch val := v.(type) {
|
|
case json.Number:
|
|
if base.Kind() == reflect.String {
|
|
// A room or code sent as a number ("room": 107).
|
|
out[key] = val.String()
|
|
continue
|
|
}
|
|
if i, err := val.Int64(); err == nil {
|
|
out[key] = i
|
|
} else if f, err := val.Float64(); err == nil {
|
|
out[key] = f
|
|
} else {
|
|
errs[key] = []string{"The " + key + " is not a number."}
|
|
}
|
|
continue
|
|
case string:
|
|
switch base {
|
|
case timeType:
|
|
tm, err := models.ParseDateTime(val)
|
|
if err != nil {
|
|
errs[key] = []string{"The " + key + " is not a valid date and time."}
|
|
continue
|
|
}
|
|
out[key] = tm
|
|
continue
|
|
case dateType:
|
|
d, err := models.ParseDate(val)
|
|
if err != nil {
|
|
errs[key] = []string{"The " + key + " is not a valid date (YYYY-MM-DD)."}
|
|
continue
|
|
}
|
|
out[key] = d
|
|
continue
|
|
}
|
|
if val == "" && ft.Kind() == reflect.Pointer {
|
|
out[key] = nil
|
|
continue
|
|
}
|
|
}
|
|
out[key] = v
|
|
}
|
|
return out, errs
|
|
}
|
|
|
|
// recordJSON is a row as an object of its columns.
|
|
func recordJSON(row any) map[string]any {
|
|
v := reflect.ValueOf(row).Elem()
|
|
cols := columns(v.Type())
|
|
out := make(map[string]any, len(cols))
|
|
for name, i := range cols {
|
|
f := v.Field(i)
|
|
if f.Kind() == reflect.Pointer {
|
|
if f.IsNil() {
|
|
out[name] = nil
|
|
continue
|
|
}
|
|
f = f.Elem()
|
|
}
|
|
switch x := f.Interface().(type) {
|
|
case time.Time:
|
|
if x.IsZero() {
|
|
out[name] = nil
|
|
} else {
|
|
out[name] = models.FormatDateTime(x)
|
|
}
|
|
case lagoon.Date:
|
|
if x.IsZero() {
|
|
out[name] = nil
|
|
} else {
|
|
out[name] = x.String()
|
|
}
|
|
default:
|
|
out[name] = x
|
|
}
|
|
}
|
|
return out
|
|
}
|