Management API: record routes for courses, meetings, class sessions, readings and deadlines (Fillable + Rules + TemporalErrors, PATCH, filters)
This commit is contained in:
@@ -162,25 +162,6 @@ func newManagedMaterialJSON(m models.Material) managedMaterialJSON {
|
||||
return out
|
||||
}
|
||||
|
||||
// ManagedCourses answers GET /courses: every course, for the course_id of
|
||||
// a material.
|
||||
func (h *Handlers) ManagedCourses(w http.ResponseWriter, r *http.Request) {
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var rows []models.Course
|
||||
if err := db.Order("semester, sort_order, name, id").Find(&rows).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
out := make([]courseJSON, 0, len(rows))
|
||||
for _, c := range rows {
|
||||
out = append(out, newCourseJSON(c))
|
||||
}
|
||||
writeData(w, out)
|
||||
}
|
||||
|
||||
// ManagedMaterials answers GET /materials: every material, drafts
|
||||
// included, newest first.
|
||||
func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
375
controllers/api/manage_records.go
Normal file
375
controllers/api/manage_records.go
Normal file
@@ -0,0 +1,375 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user