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 }