- FieldRelationContract/FieldRelationProvider bind every type: relation
field to a belongsTo foreign key or a belongsToMany pivot; activation
fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
{value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
revalidated through the same scoped query (422 and full rollback
otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
933 lines
24 KiB
Go
933 lines
24 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"net/http"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/lagoon"
|
|
"git.golem15.com/golem15/summercms/pact"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// CRUDService runs schema-projected record and bulk operations.
|
|
type CRUDService struct {
|
|
DB *gorm.DB
|
|
}
|
|
|
|
// RecordInput is a decoded JSON object. Keys are untrusted.
|
|
type RecordInput struct {
|
|
Body map[string]any
|
|
}
|
|
|
|
// BulkDeleteInput is the bulk-delete body.
|
|
type BulkDeleteInput struct {
|
|
IDs []any `json:"ids"`
|
|
}
|
|
|
|
// BulkResult is the deleted-count payload.
|
|
type BulkResult struct {
|
|
Deleted int `json:"deleted"`
|
|
}
|
|
|
|
// ValidationError is a D-10 validation_failed failure.
|
|
type ValidationError struct {
|
|
Details map[string]any
|
|
}
|
|
|
|
func (e *ValidationError) Error() string { return "validation_failed" }
|
|
|
|
// CapabilityError is a fail-closed Fill/Validate failure with controller context.
|
|
type CapabilityError struct {
|
|
ControllerID string
|
|
}
|
|
|
|
func (e *CapabilityError) Error() string {
|
|
if e == nil {
|
|
return "cabana: missing Fill/Validate capability"
|
|
}
|
|
return "cabana: controller " + e.ControllerID + ": missing Fill/Validate capability"
|
|
}
|
|
|
|
type recordNotFound struct{}
|
|
|
|
func (recordNotFound) Error() string { return "cabana: not found" }
|
|
|
|
type hasRules interface {
|
|
Rules() map[string]string
|
|
}
|
|
|
|
// ProjectWritableFields copies only activation-bound writable keys.
|
|
// Unknown keys, case variants, nested objects, and protected columns are dropped.
|
|
func ProjectWritableFields(cc *CompiledController, body map[string]any) map[string]any {
|
|
out := map[string]any{}
|
|
if cc == nil || body == nil {
|
|
return out
|
|
}
|
|
allowed := map[string]string{}
|
|
for _, field := range cc.Writable {
|
|
if field.FillKey == "" || protectedFillKey(field.Name) || protectedFillKey(field.FillKey) {
|
|
continue
|
|
}
|
|
allowed[field.Name] = field.FillKey
|
|
}
|
|
for key, val := range body {
|
|
fillKey, ok := allowed[key]
|
|
if !ok || nestedValue(val) {
|
|
continue
|
|
}
|
|
out[fillKey] = val
|
|
}
|
|
return out
|
|
}
|
|
|
|
// BindWritableFields records schema field names onto model column fill keys.
|
|
// Protected columns are omitted. A scalar field with no column fails activation.
|
|
func BindWritableFields(cc *CompiledController) error {
|
|
if cc == nil || cc.Form == nil {
|
|
return nil
|
|
}
|
|
id := ""
|
|
if cc.Controller != nil {
|
|
id = cc.Controller.ID()
|
|
}
|
|
src, ok := cc.Controller.(pact.AdminRecordSource)
|
|
if !ok || src == nil || src.NewRecord() == nil {
|
|
return nil
|
|
}
|
|
cols := modelColumns(src.NewRecord())
|
|
bindings := make([]WritableField, 0, len(cc.Form.Fields))
|
|
for _, field := range cc.Form.Fields {
|
|
if !scalarFormField(field.Type) || protectedFillKey(field.Name) {
|
|
continue
|
|
}
|
|
if _, known := cols[field.Name]; !known {
|
|
return errors.New("cabana: controller " + id + ": field " + field.Name + " is not a model column")
|
|
}
|
|
bindings = append(bindings, WritableField{Name: field.Name, FillKey: field.Name})
|
|
}
|
|
cc.Writable = bindings
|
|
return nil
|
|
}
|
|
|
|
// Create persists a projected record after Fill and Validate.
|
|
func (s CRUDService) Create(ctx context.Context, cc *CompiledController, in RecordInput) (map[string]any, error) {
|
|
res, err := s.save(ctx, cc, nil, in, false)
|
|
return res.Data, err
|
|
}
|
|
|
|
// Update persists a projected change after Fill and Validate.
|
|
func (s CRUDService) Update(ctx context.Context, cc *CompiledController, id any, in RecordInput) (map[string]any, error) {
|
|
res, err := s.save(ctx, cc, id, in, true)
|
|
return res.Data, err
|
|
}
|
|
|
|
// CreateRecord is Create plus the relation labels of the saved record (D-18).
|
|
func (s CRUDService) CreateRecord(ctx context.Context, cc *CompiledController, in RecordInput) (RecordResult, error) {
|
|
return s.save(ctx, cc, nil, in, false)
|
|
}
|
|
|
|
// UpdateRecord is Update plus the relation labels of the saved record (D-18).
|
|
func (s CRUDService) UpdateRecord(ctx context.Context, cc *CompiledController, id any, in RecordInput) (RecordResult, error) {
|
|
return s.save(ctx, cc, id, in, true)
|
|
}
|
|
|
|
// Delete removes one scoped record. A missing or out-of-scope id is a successful no-op.
|
|
func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any) (BulkResult, error) {
|
|
if s.DB == nil {
|
|
return BulkResult{}, errors.New("cabana: database is not configured")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if _, err := newWritableModel(cc); err != nil {
|
|
return BulkResult{}, err
|
|
}
|
|
var result BulkResult
|
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
target, err := newWritableModel(cc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pk, err := coercePK(target, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
|
|
var missing recordNotFound
|
|
if errors.As(err, &missing) {
|
|
result.Deleted = 0
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if err := deleteRecord(ctx, tx, cc, target); err != nil {
|
|
return err
|
|
}
|
|
result.Deleted = 1
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return BulkResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// BulkDelete removes a normalized id set in one transaction.
|
|
// An empty selection is validation_failed. A wholly absent selection is a
|
|
// successful no-op. A mixed present/absent selection conflicts and rolls back.
|
|
func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in BulkDeleteInput) (BulkResult, error) {
|
|
if s.DB == nil {
|
|
return BulkResult{}, errors.New("cabana: database is not configured")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
ids, err := normalizeIDs(in.IDs)
|
|
if err != nil {
|
|
return BulkResult{}, err
|
|
}
|
|
if _, err := newWritableModel(cc); err != nil {
|
|
return BulkResult{}, err
|
|
}
|
|
var result BulkResult
|
|
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
if err := ctx.Err(); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
proto, err := newWritableModel(cc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := lockScoped(ctx, tx, cc, proto, ids)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(rows) == 0 {
|
|
result.Deleted = 0
|
|
return nil
|
|
}
|
|
if len(rows) != len(ids) {
|
|
return partialSelection{}
|
|
}
|
|
for _, row := range rows {
|
|
if err := ctx.Err(); err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
if err := deleteRecord(ctx, tx, cc, row); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result.Deleted = len(rows)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return BulkResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Show loads one scoped record. Missing and out-of-scope ids are identical.
|
|
func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (map[string]any, error) {
|
|
res, err := s.ShowRecord(ctx, cc, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res.Data, nil
|
|
}
|
|
|
|
// ShowRecord is Show plus the relation labels of the record (D-18).
|
|
func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id any) (RecordResult, error) {
|
|
if s.DB == nil {
|
|
return RecordResult{}, errors.New("cabana: database is not configured")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
var result RecordResult
|
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
target, err := newWritableModel(cc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pk, err := coercePK(target, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
|
|
return err
|
|
}
|
|
result, err = projectFullRecord(ctx, tx, cc, target)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// projectFullRecord is the D-18 record shape: scalar writable fields, relation
|
|
// values keyed by field name, and their labels.
|
|
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
|
|
data := projectRecord(cc, model)
|
|
meta, err := projectRelationFields(ctx, tx, cc, model, data)
|
|
if err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
return RecordResult{Data: data, Meta: meta}, nil
|
|
}
|
|
|
|
func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (RecordResult, error) {
|
|
if s.DB == nil {
|
|
return RecordResult{}, errors.New("cabana: database is not configured")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if _, err := newWritableModel(cc); err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
op := "create"
|
|
if update {
|
|
op = "update"
|
|
}
|
|
// Writable relation keys are lifted before scalar projection (which drops
|
|
// every nested value); only keys present in the body are applied.
|
|
relations, err := liftRelationValues(cc, in.Body, op)
|
|
if err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
var result RecordResult
|
|
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
target, err := newWritableModel(cc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if update {
|
|
pk, err := coercePK(target, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
projected := projectOperation(cc, in.Body, op)
|
|
if err := lagoon.Fill(target, fillAllowed(cc, target, op), projected, false); err != nil {
|
|
return &CapabilityError{ControllerID: controllerID(cc)}
|
|
}
|
|
if hook, ok := target.(lagoon.HasBeforeValidate); ok && hook != nil {
|
|
if err := hook.BeforeValidate(tx); err != nil {
|
|
return &CapabilityError{ControllerID: controllerID(cc)}
|
|
}
|
|
}
|
|
rules := mergedRules(cc, target)
|
|
msgs, err := lagoon.Validate(ctx, tx, target, rules, valuesForRules(target, rules), nil)
|
|
if err != nil {
|
|
return &CapabilityError{ControllerID: controllerID(cc)}
|
|
}
|
|
if len(msgs) > 0 {
|
|
return &ValidationError{Details: validationDetails(msgs)}
|
|
}
|
|
if update {
|
|
err = formBeforeUpdate(ctx, cc, target)
|
|
} else {
|
|
err = formBeforeCreate(ctx, cc, target)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// D-18: submitted ids pass the same scoped query as the options
|
|
// endpoint; belongsTo keys land before the row write, pivot rows after.
|
|
if err := checkRelationScope(ctx, tx, cc, relations); err != nil {
|
|
return err
|
|
}
|
|
if err := assignBelongsTo(cc, target, relations); err != nil {
|
|
return err
|
|
}
|
|
if update {
|
|
err = tx.Save(target).Error
|
|
} else {
|
|
err = tx.Create(target).Error
|
|
}
|
|
if err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
if err := syncBelongsToMany(ctx, tx, cc, target, relations); err != nil {
|
|
return err
|
|
}
|
|
if update {
|
|
err = formAfterUpdate(ctx, cc, target)
|
|
} else {
|
|
err = formAfterCreate(ctx, cc, target)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result, err = projectFullRecord(ctx, tx, cc, target)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return RecordResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func writeCRUDError(w http.ResponseWriter, err error) {
|
|
var ve *ValidationError
|
|
if errors.As(err, &ve) {
|
|
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details)
|
|
return
|
|
}
|
|
var missing recordNotFound
|
|
if errors.As(err, &missing) {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
var partial partialSelection
|
|
if errors.As(err, &partial) {
|
|
WriteError(w, http.StatusConflict, "conflict", "Conflict")
|
|
return
|
|
}
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
}
|
|
|
|
func newWritableModel(cc *CompiledController) (any, error) {
|
|
id := controllerID(cc)
|
|
if cc == nil || cc.Controller == nil {
|
|
return nil, &CapabilityError{ControllerID: id}
|
|
}
|
|
src, ok := cc.Controller.(pact.AdminRecordSource)
|
|
if !ok || src == nil {
|
|
return nil, &CapabilityError{ControllerID: id}
|
|
}
|
|
model := src.NewRecord()
|
|
if model == nil {
|
|
return nil, &CapabilityError{ControllerID: id}
|
|
}
|
|
if _, ok := model.(lagoon.HasFillable); !ok {
|
|
return nil, &CapabilityError{ControllerID: id}
|
|
}
|
|
if _, ok := model.(hasRules); !ok {
|
|
return nil, &CapabilityError{ControllerID: id}
|
|
}
|
|
return model, nil
|
|
}
|
|
|
|
func controllerID(cc *CompiledController) string {
|
|
if cc == nil || cc.Controller == nil {
|
|
return ""
|
|
}
|
|
return cc.Controller.ID()
|
|
}
|
|
|
|
func loadRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, dest any, pk any) error {
|
|
col := primaryColumn(dest)
|
|
q := tx.WithContext(ctx)
|
|
if cc != nil {
|
|
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
|
|
if next := ext.FormExtendQuery(ctx, q); next != nil {
|
|
q = next
|
|
}
|
|
}
|
|
}
|
|
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where(clause.Eq{Column: clause.Column{Name: col}, Value: pk}).
|
|
Take(dest).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return recordNotFound{}
|
|
}
|
|
if err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type lifecycleError struct {
|
|
ControllerID string
|
|
}
|
|
|
|
func (e *lifecycleError) Error() string {
|
|
if e == nil {
|
|
return "cabana: lifecycle failed"
|
|
}
|
|
return "cabana: controller " + e.ControllerID + " failed"
|
|
}
|
|
|
|
func lifecycleFailure(cc *CompiledController, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var missing recordNotFound
|
|
if errors.As(err, &missing) {
|
|
return err
|
|
}
|
|
var invalid *ValidationError
|
|
if errors.As(err, &invalid) {
|
|
return err
|
|
}
|
|
var closed *CapabilityError
|
|
if errors.As(err, &closed) {
|
|
return err
|
|
}
|
|
var partial partialSelection
|
|
if errors.As(err, &partial) {
|
|
return err
|
|
}
|
|
var life *lifecycleError
|
|
if errors.As(err, &life) {
|
|
return err
|
|
}
|
|
return &lifecycleError{ControllerID: controllerID(cc)}
|
|
}
|
|
|
|
type partialSelection struct{}
|
|
|
|
func (partialSelection) Error() string { return "cabana: partial selection" }
|
|
|
|
func normalizeIDs(raw []any) ([]uint, error) {
|
|
if len(raw) == 0 {
|
|
return nil, &ValidationError{Details: map[string]any{"ids": []string{"The ids field is required."}}}
|
|
}
|
|
seen := map[uint]struct{}{}
|
|
ids := make([]uint, 0, len(raw))
|
|
for _, item := range raw {
|
|
n, err := asUint(item)
|
|
if err != nil {
|
|
return nil, &ValidationError{Details: map[string]any{"ids": []string{"The ids field must be a list of integers."}}}
|
|
}
|
|
if _, ok := seen[n]; ok {
|
|
continue
|
|
}
|
|
seen[n] = struct{}{}
|
|
ids = append(ids, n)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
return ids, nil
|
|
}
|
|
|
|
func lockScoped(ctx context.Context, tx *gorm.DB, cc *CompiledController, proto any, ids []uint) ([]any, error) {
|
|
mt := reflect.TypeOf(proto)
|
|
if mt == nil || mt.Kind() != reflect.Pointer || mt.Elem().Kind() != reflect.Struct {
|
|
return nil, &CapabilityError{ControllerID: controllerID(cc)}
|
|
}
|
|
holder := reflect.New(reflect.SliceOf(mt.Elem()))
|
|
q := tx.WithContext(ctx).Model(proto)
|
|
if cc != nil {
|
|
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
|
|
if next := ext.ListExtendQuery(ctx, q); next != nil {
|
|
q = next
|
|
}
|
|
}
|
|
}
|
|
col := primaryColumn(proto)
|
|
vals := make([]any, len(ids))
|
|
for i, id := range ids {
|
|
vals[i] = castPK(proto, id)
|
|
}
|
|
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where(clause.IN{Column: clause.Column{Name: col}, Values: vals}).
|
|
Order(clause.OrderByColumn{Column: clause.Column{Name: col}, Desc: false}).
|
|
Find(holder.Interface()).Error
|
|
if err != nil {
|
|
return nil, lifecycleFailure(cc, err)
|
|
}
|
|
values := holder.Elem()
|
|
out := make([]any, values.Len())
|
|
for i := 0; i < values.Len(); i++ {
|
|
out[i] = values.Index(i).Addr().Interface()
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return pkUint(out[i]) < pkUint(out[j]) })
|
|
return out, nil
|
|
}
|
|
|
|
func pkUint(model any) uint {
|
|
v := reflect.ValueOf(model)
|
|
for v.Kind() == reflect.Pointer {
|
|
if v.IsNil() {
|
|
return 0
|
|
}
|
|
v = v.Elem()
|
|
}
|
|
field := fieldByColumn(v, primaryColumn(model))
|
|
if !field.IsValid() || !field.CanInterface() {
|
|
return 0
|
|
}
|
|
n, err := asUint(field.Interface())
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
func formBeforeCreate(ctx context.Context, cc *CompiledController, model any) error {
|
|
hook, ok := cc.Controller.(pact.FormBeforeCreate)
|
|
if !ok || hook == nil {
|
|
return nil
|
|
}
|
|
return lifecycleFailure(cc, hook.FormBeforeCreate(ctx, model))
|
|
}
|
|
|
|
func formAfterCreate(ctx context.Context, cc *CompiledController, model any) error {
|
|
hook, ok := cc.Controller.(pact.FormAfterCreate)
|
|
if !ok || hook == nil {
|
|
return nil
|
|
}
|
|
return lifecycleFailure(cc, hook.FormAfterCreate(ctx, model))
|
|
}
|
|
|
|
func formBeforeUpdate(ctx context.Context, cc *CompiledController, model any) error {
|
|
hook, ok := cc.Controller.(pact.FormBeforeUpdate)
|
|
if !ok || hook == nil {
|
|
return nil
|
|
}
|
|
return lifecycleFailure(cc, hook.FormBeforeUpdate(ctx, model))
|
|
}
|
|
|
|
func formAfterUpdate(ctx context.Context, cc *CompiledController, model any) error {
|
|
hook, ok := cc.Controller.(pact.FormAfterUpdate)
|
|
if !ok || hook == nil {
|
|
return nil
|
|
}
|
|
return lifecycleFailure(cc, hook.FormAfterUpdate(ctx, model))
|
|
}
|
|
|
|
func deleteRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) error {
|
|
if hook, ok := cc.Controller.(pact.FormBeforeDelete); ok && hook != nil {
|
|
if err := lifecycleFailure(cc, hook.FormBeforeDelete(ctx, model)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.WithContext(ctx).Delete(model).Error; err != nil {
|
|
return lifecycleFailure(cc, err)
|
|
}
|
|
if hook, ok := cc.Controller.(pact.FormAfterDelete); ok && hook != nil {
|
|
if err := lifecycleFailure(cc, hook.FormAfterDelete(ctx, model)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeObject(r *http.Request) (map[string]any, error) {
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.UseNumber()
|
|
var body map[string]any
|
|
if err := dec.Decode(&body); err != nil {
|
|
return nil, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
|
|
}
|
|
if body == nil {
|
|
body = map[string]any{}
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func pathID(r *http.Request) (uint, error) {
|
|
raw := strings.TrimSpace(r.PathValue("id"))
|
|
n, err := strconv.ParseUint(raw, 10, 64)
|
|
if raw == "" || err != nil {
|
|
return 0, &ValidationError{Details: map[string]any{"id": []string{"The id field must be an integer."}}}
|
|
}
|
|
return uint(n), nil
|
|
}
|
|
|
|
func projectOperation(cc *CompiledController, body map[string]any, op string) map[string]any {
|
|
projected := ProjectWritableFields(cc, body)
|
|
if op == "" || cc == nil {
|
|
return projected
|
|
}
|
|
filtered := map[string]any{}
|
|
for _, field := range cc.Writable {
|
|
if !contextAllows(cc, field.Name, op) {
|
|
continue
|
|
}
|
|
if val, ok := projected[field.FillKey]; ok {
|
|
filtered[field.FillKey] = val
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func fillAllowed(cc *CompiledController, model any, op string) []string {
|
|
fillable, ok := model.(lagoon.HasFillable)
|
|
if !ok || fillable == nil {
|
|
return nil
|
|
}
|
|
allowed := map[string]struct{}{}
|
|
for _, key := range fillable.Fillable() {
|
|
if !protectedFillKey(key) {
|
|
allowed[key] = struct{}{}
|
|
}
|
|
}
|
|
out := make([]string, 0, len(allowed))
|
|
if cc == nil {
|
|
return out
|
|
}
|
|
for _, field := range cc.Writable {
|
|
if protectedFillKey(field.FillKey) {
|
|
continue
|
|
}
|
|
if _, ok := allowed[field.FillKey]; !ok || !contextAllows(cc, field.Name, op) {
|
|
continue
|
|
}
|
|
out = append(out, field.FillKey)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mergedRules(cc *CompiledController, model any) map[string]string {
|
|
out := map[string]string{}
|
|
if rules, ok := model.(hasRules); ok && rules != nil {
|
|
for key, rule := range rules.Rules() {
|
|
out[key] = rule
|
|
}
|
|
}
|
|
if cc == nil || cc.Form == nil {
|
|
return out
|
|
}
|
|
for _, field := range cc.Form.Fields {
|
|
// Relation fields are not writable columns. required stays on the
|
|
// schema for the client, but it cannot be checked by Fill.
|
|
if field.Required && scalarFormField(field.Type) {
|
|
out[field.Name] = mergeRequired(out[field.Name])
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mergeRequired(rule string) string {
|
|
if strings.TrimSpace(rule) == "" {
|
|
return "required"
|
|
}
|
|
for _, tok := range strings.Split(rule, "|") {
|
|
name, _, _ := strings.Cut(strings.TrimSpace(tok), ":")
|
|
if name == "required" {
|
|
return rule
|
|
}
|
|
}
|
|
return rule + "|required"
|
|
}
|
|
|
|
func valuesForRules(model any, rules map[string]string) map[string]any {
|
|
v := reflect.ValueOf(model)
|
|
for v.Kind() == reflect.Pointer {
|
|
if v.IsNil() {
|
|
return map[string]any{}
|
|
}
|
|
v = v.Elem()
|
|
}
|
|
out := map[string]any{}
|
|
for field := range rules {
|
|
f := fieldByColumn(v, field)
|
|
if f.IsValid() && f.CanInterface() {
|
|
out[field] = f.Interface()
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func validationDetails(msgs map[string][]string) map[string]any {
|
|
out := make(map[string]any, len(msgs))
|
|
for key, messages := range msgs {
|
|
out[key] = messages
|
|
}
|
|
return out
|
|
}
|
|
|
|
func projectRecord(cc *CompiledController, model any) map[string]any {
|
|
v := reflect.ValueOf(model)
|
|
for v.Kind() == reflect.Pointer {
|
|
if v.IsNil() {
|
|
return map[string]any{}
|
|
}
|
|
v = v.Elem()
|
|
}
|
|
out := map[string]any{}
|
|
if id := fieldByColumn(v, primaryColumn(model)); id.IsValid() && id.CanInterface() {
|
|
out["id"] = id.Interface()
|
|
}
|
|
if cc == nil {
|
|
return out
|
|
}
|
|
for _, field := range cc.Writable {
|
|
if protectedFillKey(field.FillKey) {
|
|
continue
|
|
}
|
|
f := fieldByColumn(v, field.FillKey)
|
|
if f.IsValid() && f.CanInterface() {
|
|
out[field.Name] = f.Interface()
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func modelColumns(model any) map[string]struct{} {
|
|
t := reflect.TypeOf(model)
|
|
for t != nil && t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
cols := map[string]struct{}{}
|
|
if t == nil || t.Kind() != reflect.Struct {
|
|
return cols
|
|
}
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
if field.PkgPath != "" {
|
|
continue
|
|
}
|
|
name := gormColumn(field)
|
|
if name != "" {
|
|
cols[name] = struct{}{}
|
|
}
|
|
}
|
|
return cols
|
|
}
|
|
|
|
func scalarFormField(typ string) bool {
|
|
switch typ {
|
|
case "text", "textarea", "number", "checkbox", "switch", "dropdown":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func protectedFillKey(key string) bool {
|
|
switch strings.ToLower(key) {
|
|
case "id", "created_at", "updated_at", "deleted_at",
|
|
"owner_id", "user_id", "collection_id", "organisation_id", "organization_id",
|
|
"scope_id", "role_id", "permissions",
|
|
"is_superuser", "is_system", "is_activated", "password":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func nestedValue(val any) bool {
|
|
switch val.(type) {
|
|
case map[string]any, []any:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func contextAllows(cc *CompiledController, name, op string) bool {
|
|
if op == "" || cc == nil || cc.Form == nil {
|
|
return true
|
|
}
|
|
for _, field := range cc.Form.Fields {
|
|
if field.Name != name {
|
|
continue
|
|
}
|
|
if field.Context == nil || len(field.Context.values) == 0 {
|
|
return true
|
|
}
|
|
for _, value := range field.Context.values {
|
|
if value == op {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func coercePK(model any, id any) (any, error) {
|
|
n, err := asUint(id)
|
|
if err != nil {
|
|
return nil, &ValidationError{Details: map[string]any{"id": []string{"The id field must be an integer."}}}
|
|
}
|
|
return castPK(model, n), nil
|
|
}
|
|
|
|
func castPK(model any, n uint) any {
|
|
t := reflect.TypeOf(model)
|
|
for t != nil && t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
if t == nil || t.Kind() != reflect.Struct {
|
|
return n
|
|
}
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
if !strings.Contains(field.Tag.Get("gorm"), "primaryKey") {
|
|
continue
|
|
}
|
|
switch field.Type.Kind() {
|
|
case reflect.Uint:
|
|
return uint(n)
|
|
case reflect.Uint32:
|
|
return uint32(n)
|
|
case reflect.Uint64:
|
|
return uint64(n)
|
|
case reflect.Int:
|
|
return int(n)
|
|
case reflect.Int64:
|
|
return int64(n)
|
|
case reflect.String:
|
|
return strconv.FormatUint(uint64(n), 10)
|
|
default:
|
|
return n
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func asUint(id any) (uint, error) {
|
|
switch n := id.(type) {
|
|
case uint:
|
|
return n, nil
|
|
case uint32:
|
|
return uint(n), nil
|
|
case uint64:
|
|
if uint64(uint(n)) != n {
|
|
return 0, errBadID
|
|
}
|
|
return uint(n), nil
|
|
case int:
|
|
if n < 0 {
|
|
return 0, errBadID
|
|
}
|
|
return uint(n), nil
|
|
case int64:
|
|
if n < 0 {
|
|
return 0, errBadID
|
|
}
|
|
return uint(n), nil
|
|
case float64:
|
|
if n < 0 || n != math.Trunc(n) || n > math.MaxUint32 && strconv.IntSize == 32 {
|
|
return 0, errBadID
|
|
}
|
|
return uint(n), nil
|
|
case json.Number:
|
|
i, err := n.Int64()
|
|
if err != nil || i < 0 {
|
|
return 0, errBadID
|
|
}
|
|
return uint(i), nil
|
|
case string:
|
|
i, err := strconv.ParseUint(n, 10, 64)
|
|
if err != nil {
|
|
return 0, errBadID
|
|
}
|
|
return uint(i), nil
|
|
default:
|
|
return 0, errBadID
|
|
}
|
|
}
|
|
|
|
var errBadID = errors.New("cabana: bad id")
|