Files
sm-bm-plugin/controllers/api/manage_records.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
}