Files
summercms/cabana/crud.go
Jakub Zych fe04dbc89e feat(10-02): relation field options and relation saves with labels
- 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
2026-09-27 16:00:52 +02:00

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")