diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index b8a6386..b1eecce 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -69,6 +69,10 @@ ], "type": "object" }, + "cabana.AdminRecord": { + "additionalProperties": {}, + "type": "object" + }, "cabana.AdminRoleSummary": { "properties": { "code": { @@ -273,6 +277,24 @@ ], "type": "object" }, + "cabana.ListEnvelope-array_cabana_RelationOption": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/cabana.RelationOption" + }, + "type": "array" + }, + "meta": { + "$ref": "#/components/schemas/cabana.ListMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.ListFilter": { "properties": { "column": { @@ -481,6 +503,53 @@ ], "type": "object" }, + "cabana.RecordEnvelope": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.AdminRecord" + }, + "meta": { + "$ref": "#/components/schemas/cabana.RecordMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, + "cabana.RecordMeta": { + "properties": { + "labels": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/cabana.RelationOption" + }, + "type": "array" + }, + "type": "object" + } + }, + "required": [ + "labels" + ], + "type": "object" + }, + "cabana.RelationOption": { + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "integer" + } + }, + "required": [ + "label", + "value" + ], + "type": "object" + }, "cabana.RowAction": { "properties": { "label": { @@ -1144,6 +1213,7 @@ ] }, "post": { + "description": "Relation fields are sent by field name with ids ({\"genre\": 3, \"artists\": [4, 9]}); the response carries the same shape plus meta.labels.", "parameters": [ { "description": "Vendor", @@ -1174,15 +1244,15 @@ } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.RecordEnvelope" } } }, - "description": "OK" + "description": "Created" }, "401": { "content": { @@ -1310,6 +1380,134 @@ ] } }, + "/{vendor}/{plugin}/{controller}/fields/{field}/options": { + "get": { + "description": "Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404.", + "parameters": [ + { + "description": "Vendor", + "in": "path", + "name": "vendor", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Plugin", + "in": "path", + "name": "plugin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Controller", + "in": "path", + "name": "controller", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Relation field name", + "in": "path", + "name": "field", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Case-insensitive label search", + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "description": "Page", + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "description": "Options per page (1-100, default 20)", + "in": "query", + "name": "per_page", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ListEnvelope-array_cabana_RelationOption" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Relation field options", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/schema/form": { "get": { "parameters": [ @@ -1707,7 +1905,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.RecordEnvelope" } } }, @@ -1798,7 +1996,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.RecordEnvelope" } } }, @@ -1824,6 +2022,16 @@ }, "description": "Forbidden" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, "422": { "content": { "application/json": { diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index ad07c17..c176724 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -568,7 +568,10 @@ export interface paths { }; }; put?: never; - /** Create an admin record */ + /** + * Create an admin record + * @description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels. + */ post: { parameters: { query?: never; @@ -585,13 +588,13 @@ export interface paths { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.RecordEnvelope"]; }; }; /** @description Unauthorized */ @@ -699,6 +702,97 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/fields/{field}/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Relation field options + * @description Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404. + */ + get: { + parameters: { + query?: { + /** @description Case-insensitive label search */ + search?: string; + /** @description Page */ + page?: number; + /** @description Options per page (1-100, default 20) */ + per_page?: number; + }; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Relation field name */ + field: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ListEnvelope-array_cabana_RelationOption"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/schema/form": { parameters: { query?: never; @@ -943,7 +1037,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.RecordEnvelope"]; }; }; /** @description Unauthorized */ @@ -1000,7 +1094,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.RecordEnvelope"]; }; }; /** @description Unauthorized */ @@ -1021,6 +1115,15 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -1396,6 +1499,9 @@ export interface components { login: string; role?: components["schemas"]["cabana.AdminRoleSummary"]; }; + "cabana.AdminRecord": { + [key: string]: unknown; + }; "cabana.AdminRoleSummary": { code: string; id: number; @@ -1453,6 +1559,10 @@ export interface components { }[]; meta: components["schemas"]["cabana.ListMeta"]; }; + "cabana.ListEnvelope-array_cabana_RelationOption": { + data: components["schemas"]["cabana.RelationOption"][]; + meta: components["schemas"]["cabana.ListMeta"]; + }; "cabana.ListFilter": { column?: string; falseValue?: components["schemas"]["cabana.jsonScalar"]; @@ -1504,6 +1614,19 @@ export interface components { order: number; sideMenu: components["schemas"]["cabana.NavigationEntry"][]; }; + "cabana.RecordEnvelope": { + data: components["schemas"]["cabana.AdminRecord"]; + meta: components["schemas"]["cabana.RecordMeta"]; + }; + "cabana.RecordMeta": { + labels: { + [key: string]: components["schemas"]["cabana.RelationOption"][]; + }; + }; + "cabana.RelationOption": { + label: string; + value: number; + }; "cabana.RowAction": { label?: string; name: string; diff --git a/cabana/admin_openapi.go b/cabana/admin_openapi.go index 108dfa8..35b4720 100644 --- a/cabana/admin_openapi.go +++ b/cabana/admin_openapi.go @@ -257,6 +257,28 @@ func AdminFormSchema() {} // @Router /{vendor}/{plugin}/{controller}/schema/relation/{name} [get] func AdminRelationSchema() {} +// AdminFieldOptions documents the relation field options route (D-17). +// +// @Summary Relation field options +// @Description Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404. +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param field path string true "Relation field name" +// @Param search query string false "Case-insensitive label search" +// @Param page query integer false "Page" +// @Param per_page query integer false "Options per page (1-100, default 20)" +// @Success 200 {object} ListEnvelope[[]RelationOption] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get] +func AdminFieldOptions() {} + // AdminList documents the record list route. // // @Summary List admin records @@ -281,6 +303,7 @@ func AdminList() {} // AdminCreate documents the record create route. // // @Summary Create an admin record +// @Description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels. // @Tags admin // @Accept json // @Produce json @@ -288,7 +311,7 @@ func AdminList() {} // @Param vendor path string true "Vendor" // @Param plugin path string true "Plugin" // @Param controller path string true "Controller" -// @Success 200 {object} SuccessEnvelope +// @Success 201 {object} RecordEnvelope // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope @@ -322,7 +345,7 @@ func AdminBulkDelete() {} // @Param plugin path string true "Plugin" // @Param controller path string true "Controller" // @Param id path integer true "Record id" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} RecordEnvelope // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope @@ -340,9 +363,10 @@ func AdminShow() {} // @Param plugin path string true "Plugin" // @Param controller path string true "Controller" // @Param id path integer true "Record id" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} RecordEnvelope // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller}/{id} [put] func AdminUpdate() {} diff --git a/cabana/contracts.go b/cabana/contracts.go index 41f8a6c..02beba5 100644 --- a/cabana/contracts.go +++ b/cabana/contracts.go @@ -71,6 +71,8 @@ type CompiledController struct { Form *FormSchema Relations map[string]*CompiledRelation Writable []WritableField + // FieldRelations are the form's `type: relation` fields keyed by field name. + FieldRelations map[string]*CompiledFieldRelation } // Registry is the immutable controller map keyed by controller ID. diff --git a/cabana/crud.go b/cabana/crud.go index c5a2fa0..ca3b6ec 100644 --- a/cabana/crud.go +++ b/cabana/crud.go @@ -119,11 +119,23 @@ func BindWritableFields(cc *CompiledController) error { // Create persists a projected record after Fill and Validate. func (s CRUDService) Create(ctx context.Context, cc *CompiledController, in RecordInput) (map[string]any, error) { - return s.save(ctx, cc, nil, in, false) + 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) } @@ -226,13 +238,22 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in // 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 nil, errors.New("cabana: database is not configured") + return RecordResult{}, errors.New("cabana: database is not configured") } if ctx == nil { ctx = context.Background() } - var result map[string]any + var result RecordResult err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { tx = tx.WithContext(ctx) target, err := newWritableModel(cc) @@ -246,35 +267,54 @@ func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) ( if err := loadRecord(ctx, tx, cc, target, pk); err != nil { return err } - result = projectRecord(cc, target) - return nil + result, err = projectFullRecord(ctx, tx, cc, target) + return err }) if err != nil { - return nil, err + return RecordResult{}, err } return result, nil } -func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, in RecordInput, update bool) (map[string]any, error) { +// 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 nil, errors.New("cabana: database is not configured") + return RecordResult{}, errors.New("cabana: database is not configured") } if ctx == nil { ctx = context.Background() } if _, err := newWritableModel(cc); err != nil { - return nil, err + return RecordResult{}, err } - var result map[string]any - err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + 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 } - op := "create" if update { - op = "update" pk, err := coercePK(target, id) if err != nil { return err @@ -301,19 +341,32 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i return &ValidationError{Details: validationDetails(msgs)} } if update { - if err := formBeforeUpdate(ctx, cc, target); err != nil { - return err - } + 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 { - if err := formBeforeCreate(ctx, cc, target); err != nil { - return err - } 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 { @@ -322,11 +375,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i if err != nil { return err } - result = projectRecord(cc, target) - return nil + result, err = projectFullRecord(ctx, tx, cc, target) + return err }) if err != nil { - return nil, err + return RecordResult{}, err } return result, nil } diff --git a/cabana/http.go b/cabana/http.go index 99b1c38..ec6f82e 100644 --- a/cabana/http.go +++ b/cabana/http.go @@ -212,8 +212,11 @@ func (s *service) mount(r pact.Router) { constrainController(g) g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord)) constrainController(g) - g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked) - constrainRelation(g) + // Six-segment GET routes share one pattern: ServeMux rejects the + // relation list next to the field options route (neither is more + // specific), so nestedGet dispatches on the literal segments. + g.Get("/{vendor}/{plugin}/{controller}/{id}/{segment}/{name}", s.nestedGet) + constrainNested(g) g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates) constrainRelation(g) g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink)) @@ -240,6 +243,32 @@ func constrainRelation(g pact.Router) { g.Where("name", "[A-Za-z_][A-Za-z0-9_]*") } +func constrainNested(g pact.Router) { + constrainController(g) + g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*") + g.Where("name", "[A-Za-z_][A-Za-z0-9_]*") +} + +// nestedGet serves the logical routes +// +// GET /{vendor}/{plugin}/{controller}/{id}/relations/{name} +// GET /{vendor}/{plugin}/{controller}/fields/{field}/options +// +// A numeric id never equals a literal segment, so the dispatch is unambiguous. +// Anything else is the D-10 not_found envelope, as an unmatched API path. +func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) { + id, segment, name := r.PathValue("id"), r.PathValue("segment"), r.PathValue("name") + switch { + case id == "fields" && name == "options": + r.SetPathValue("field", segment) + s.fieldOptions(w, r) + case segment == "relations": + s.relationLinked(w, r) + default: + writeNotFound(w, r) + } +} + func constrainSetting(g pact.Router) { g.Where("code", "[A-Za-z_][A-Za-z0-9_-]*") } @@ -509,12 +538,12 @@ func (s *service) show(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } - rec, err := svc.Show(r.Context(), cc, id) + rec, err := svc.ShowRecord(r.Context(), cc, id) if err != nil { writeCRUDError(w, err) return } - WriteData(w, http.StatusOK, rec, nil) + WriteData(w, http.StatusOK, rec.Data, rec.Meta) }) } @@ -530,12 +559,12 @@ func (s *service) create(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } - rec, err := svc.Create(r.Context(), cc, RecordInput{Body: body}) + rec, err := svc.CreateRecord(r.Context(), cc, RecordInput{Body: body}) if err != nil { writeCRUDError(w, err) return } - WriteData(w, http.StatusCreated, rec, nil) + WriteData(w, http.StatusCreated, rec.Data, rec.Meta) }) } @@ -556,12 +585,12 @@ func (s *service) update(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } - rec, err := svc.Update(r.Context(), cc, id, RecordInput{Body: body}) + rec, err := svc.UpdateRecord(r.Context(), cc, id, RecordInput{Body: body}) if err != nil { writeCRUDError(w, err) return } - WriteData(w, http.StatusOK, rec, nil) + WriteData(w, http.StatusOK, rec.Data, rec.Meta) }) } diff --git a/cabana/phase09_contract_test.go b/cabana/phase09_contract_test.go index dd565b5..3c21e95 100644 --- a/cabana/phase09_contract_test.go +++ b/cabana/phase09_contract_test.go @@ -66,8 +66,10 @@ func TestPhase09ContractInventory(t *testing.T) { t.Fatalf("duplicate contract route %s", key) } seen[key] = true - if _, ok := op.Responses["200"]; !ok { - t.Fatalf("%s has no 200 response", key) + _, ok200 := op.Responses["200"] + _, ok201 := op.Responses["201"] + if !ok200 && !ok201 { + t.Fatalf("%s has no 200 or 201 response", key) } if public[key] { if _, ok := op.Responses["401"]; !ok { @@ -99,11 +101,7 @@ func splitRoute(key string) (method, path string, ok bool) { return "", "", false } -func pathsOf(routes []struct { - key string - public bool - spa bool -}) map[string]bool { +func pathsOf(routes []adminRoute) map[string]bool { out := map[string]bool{} for _, route := range routes { if route.spa { diff --git a/cabana/registry.go b/cabana/registry.go index 303cb5f..3c4d8c0 100644 --- a/cabana/registry.go +++ b/cabana/registry.go @@ -73,12 +73,17 @@ func compileRegistry(items []controllerRef) (*Registry, error) { if err != nil { return nil, err } + fieldRelations, err := compileFieldRelations(item.plugin.ID(), item.ctl, form) + if err != nil { + return nil, err + } compiled := &CompiledController{ - PluginID: item.plugin.ID(), - Controller: item.ctl, - List: list, - Form: form, - Relations: relations, + PluginID: item.plugin.ID(), + Controller: item.ctl, + List: list, + Form: form, + Relations: relations, + FieldRelations: fieldRelations, } if err := BindWritableFields(compiled); err != nil { return nil, err diff --git a/cabana/relation.go b/cabana/relation.go index 4004acd..98f6e68 100644 --- a/cabana/relation.go +++ b/cabana/relation.go @@ -495,21 +495,31 @@ func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, return q, target, nil } -func normalizeRelationQuery(schema *RelationSchema, candidates bool, in RelationQuery) (int, int, string, bool, error) { +// normalizeRelationPage applies the Phase 9 relation paging limits: page is +// a positive integer (default 1), per_page is 1..100 (default 20). +func normalizeRelationPage(rawPage, rawPerPage string) (int, int, error) { page, per := 1, 20 var err error - if in.Page != "" { - page, err = parsePositive(in.Page) + if rawPage != "" { + page, err = parsePositive(rawPage) if err != nil { - return 0, 0, "", false, relationInvalid("page", "must be a positive integer") + return 0, 0, relationInvalid("page", "must be a positive integer") } } - if in.PerPage != "" { - per, err = parsePositive(in.PerPage) + if rawPerPage != "" { + per, err = parsePositive(rawPerPage) if err != nil || per > 100 { - return 0, 0, "", false, relationInvalid("per_page", "must be between 1 and 100") + return 0, 0, relationInvalid("per_page", "must be between 1 and 100") } } + return page, per, nil +} + +func normalizeRelationQuery(schema *RelationSchema, candidates bool, in RelationQuery) (int, int, string, bool, error) { + page, per, err := normalizeRelationPage(in.Page, in.PerPage) + if err != nil { + return 0, 0, "", false, err + } panel := schema.View if candidates { panel = schema.Manage diff --git a/cabana/relation_field.go b/cabana/relation_field.go new file mode 100644 index 0000000..22432bd --- /dev/null +++ b/cabana/relation_field.go @@ -0,0 +1,652 @@ +package cabana + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "reflect" + "sort" + "strings" + + "git.golem15.com/golem15/summercms/pact" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// FieldRelationContract binds one fields.yaml `type: relation` field to its +// related model (D-17, D-18). Framework code never guesses a plugin table, +// pivot or foreign key: every name below comes from the controller. +type FieldRelationContract struct { + // Field is the fields.yaml key ("genre", "artists"). + Field string + // Kind is "belongsTo" or "belongsToMany". + Kind string + // NewRelated returns a pointer to the related model. + NewRelated func() any + // ForeignKey is the belongsTo column on the parent model. + ForeignKey string + // NewPivot returns a pointer to the belongsToMany join model. + NewPivot func() any + // ParentForeignKey and RelatedForeignKey are the pivot columns. + ParentForeignKey string + RelatedForeignKey string + // OrderColumn is an optional pivot column set to the submitted array index. + OrderColumn string + // LabelColumn is the physical label column on the related model. Empty + // means the field's nameFrom, mapped through pact.ListRelationColumnMapper + // when the controller implements it. + LabelColumn string +} + +// FieldRelationProvider is implemented by admin controllers whose form +// declares `type: relation` fields. +type FieldRelationProvider interface { + AdminFieldRelations() []FieldRelationContract +} + +// RelationOption is one relation choice or label: the related primary key and +// its label column value. +type RelationOption struct { + Value uint `json:"value"` + Label string `json:"label"` +} + +// RecordMeta is the record envelope meta: display labels per relation field, +// in the same order as the ids in data. +type RecordMeta struct { + Labels map[string][]RelationOption `json:"labels"` +} + +// RecordEnvelope is the show, create and update response. +type RecordEnvelope struct { + Data AdminRecord `json:"data"` + Meta RecordMeta `json:"meta"` +} + +// RecordResult is one projected record plus its relation labels. +type RecordResult struct { + Data map[string]any + Meta RecordMeta +} + +// CompiledFieldRelation is one relation field after activation checks. +type CompiledFieldRelation struct { + Contract FieldRelationContract + // LabelColumn is the resolved physical label column. + LabelColumn string + // Multiple is true for belongsToMany. + Multiple bool + // ReadOnly is true for a belongsTo whose foreign key is a protected fill + // key (D-26): it is shown with its label but never written and has no + // options endpoint. + ReadOnly bool + // Nullable is true when a belongsTo foreign key accepts null. + Nullable bool +} + +const ( + relationKindBelongsTo = "belongsTo" + relationKindBelongsToMany = "belongsToMany" +) + +// compileFieldRelations binds every `type: relation` field to exactly one +// controller contract and marks the compiled form fields multiple or +// read-only. Any mismatch is a boot error naming plugin, controller and field. +func compileFieldRelations(pluginID string, ctl pact.AdminController, form *FormSchema) (map[string]*CompiledFieldRelation, error) { + out := map[string]*CompiledFieldRelation{} + fail := func(field string, err error) error { + return bootErr(pluginID, ctl.ID(), "config_form.yaml", fmt.Errorf("field %s: %w", field, err)) + } + indexes := map[string]int{} + var order []string + if form != nil { + for i, field := range form.Fields { + if field.Type == "relation" { + indexes[field.Name] = i + order = append(order, field.Name) + } + } + } + provider, hasProvider := ctl.(FieldRelationProvider) + var contracts []FieldRelationContract + if hasProvider && provider != nil { + contracts = provider.AdminFieldRelations() + } + if len(order) == 0 && len(contracts) == 0 { + return out, nil + } + byField := map[string]FieldRelationContract{} + for _, contract := range contracts { + if _, dup := byField[contract.Field]; dup { + return nil, fail(contract.Field, errors.New("duplicate relation contract")) + } + byField[contract.Field] = contract + if _, ok := indexes[contract.Field]; !ok { + return nil, fail(contract.Field, errors.New("relation contract names a field that is not a relation field in fields.yaml")) + } + } + src, ok := ctl.(pact.AdminRecordSource) + if !ok || src == nil || src.NewRecord() == nil { + return nil, fail(order[0], errors.New("relation fields require an AdminRecordSource")) + } + parent := src.NewRecord() + for _, name := range order { + contract, declared := byField[name] + if !declared { + if !hasProvider { + return nil, fail(name, errors.New("type relation requires AdminFieldRelations on the controller")) + } + return nil, fail(name, errors.New("has no relation contract")) + } + field := &form.Fields[indexes[name]] + compiled, err := compileFieldRelation(ctl, *field, contract, parent) + if err != nil { + return nil, fail(name, err) + } + field.Multiple = compiled.Multiple + field.ReadOnly = compiled.ReadOnly + out[name] = compiled + } + return out, nil +} + +func compileFieldRelation(ctl pact.AdminController, field FormField, contract FieldRelationContract, parent any) (*CompiledFieldRelation, error) { + if contract.NewRelated == nil || contract.NewRelated() == nil { + return nil, errors.New("relation contract requires a related model") + } + related := contract.NewRelated() + relatedCols := modelColumns(related) + if _, ok := relatedCols[primaryColumn(related)]; !ok { + return nil, fmt.Errorf("related model has no primary key column %s", primaryColumn(related)) + } + label := strings.TrimSpace(contract.LabelColumn) + if label == "" { + label = field.NameFrom + if mapper, ok := ctl.(pact.ListRelationColumnMapper); ok && mapper != nil && label != "" { + if mapped, ok := mapper.ListRelationColumn(field.Name, label); ok && mapped != "" { + label = mapped + } + } + } + if !identifier(label) { + return nil, errors.New("relation needs nameFrom or a LabelColumn") + } + if _, ok := relatedCols[label]; !ok { + return nil, fmt.Errorf("related model is missing label column %s", label) + } + out := &CompiledFieldRelation{Contract: contract, LabelColumn: label} + parentCols := modelColumns(parent) + switch contract.Kind { + case relationKindBelongsTo: + if contract.NewPivot != nil || contract.ParentForeignKey != "" || contract.RelatedForeignKey != "" || contract.OrderColumn != "" { + return nil, errors.New("belongsTo cannot declare pivot columns") + } + if !identifier(contract.ForeignKey) { + return nil, errors.New("belongsTo requires a ForeignKey") + } + if _, ok := parentCols[contract.ForeignKey]; !ok { + return nil, fmt.Errorf("parent model is missing foreign key column %s", contract.ForeignKey) + } + fk, ok := structFieldByColumn(parent, contract.ForeignKey) + if !ok || !uintLike(fk.Type) { + return nil, fmt.Errorf("foreign key %s must be an unsigned integer column", contract.ForeignKey) + } + out.Nullable = fk.Type.Kind() == reflect.Pointer + out.ReadOnly = protectedFillKey(contract.ForeignKey) + case relationKindBelongsToMany: + if contract.ForeignKey != "" { + return nil, errors.New("belongsToMany cannot declare a ForeignKey") + } + if contract.NewPivot == nil || contract.NewPivot() == nil { + return nil, errors.New("belongsToMany requires a pivot model") + } + pivot := contract.NewPivot() + if reflect.TypeOf(pivot).Kind() != reflect.Pointer || reflect.TypeOf(pivot).Elem().Kind() != reflect.Struct { + return nil, errors.New("pivot model must be a struct pointer") + } + pivotCols := modelColumns(pivot) + for _, col := range []string{contract.ParentForeignKey, contract.RelatedForeignKey} { + if !identifier(col) { + return nil, errors.New("belongsToMany requires ParentForeignKey and RelatedForeignKey") + } + if _, ok := pivotCols[col]; !ok { + return nil, fmt.Errorf("pivot model is missing column %s", col) + } + } + if contract.ParentForeignKey == contract.RelatedForeignKey { + return nil, errors.New("pivot foreign keys must differ") + } + if contract.OrderColumn != "" { + if !identifier(contract.OrderColumn) { + return nil, fmt.Errorf("order column %q is not an identifier", contract.OrderColumn) + } + if _, ok := pivotCols[contract.OrderColumn]; !ok { + return nil, fmt.Errorf("pivot model is missing order column %s", contract.OrderColumn) + } + if contract.OrderColumn == contract.ParentForeignKey || contract.OrderColumn == contract.RelatedForeignKey { + return nil, errors.New("order column must not be a pivot foreign key") + } + } + out.Multiple = true + default: + return nil, fmt.Errorf("unknown relation kind %s (want belongsTo or belongsToMany)", contract.Kind) + } + return out, nil +} + +func structFieldByColumn(model any, column string) (reflect.StructField, bool) { + t := reflect.TypeOf(model) + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil || t.Kind() != reflect.Struct { + return reflect.StructField{}, false + } + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath == "" && gormColumn(field) == column { + return field, true + } + } + return reflect.StructField{}, false +} + +func uintLike(t reflect.Type) bool { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + switch t.Kind() { + case reflect.Uint, reflect.Uint32, reflect.Uint64, reflect.Int, reflect.Int32, reflect.Int64: + return true + default: + return false + } +} + +// writableFieldRelation returns a relation field that offers options and +// accepts values. Read-only and unknown fields are not found. +func writableFieldRelation(cc *CompiledController, name string) (*CompiledFieldRelation, bool) { + if cc == nil || !identifier(name) { + return nil, false + } + fr, ok := cc.FieldRelations[name] + if !ok || fr == nil || fr.ReadOnly { + return nil, false + } + return fr, true +} + +// scopedRelationQuery is the one query that both serves options and +// revalidates submitted ids, so the options hook is never only cosmetic. +func scopedRelationQuery(ctx context.Context, db *gorm.DB, cc *CompiledController, field string, fr *CompiledFieldRelation) *gorm.DB { + q := db.WithContext(ctx).Model(fr.Contract.NewRelated()) + if ext, ok := cc.Controller.(pact.RelationExtendOptionsQuery); ok && ext != nil { + if next := ext.RelationExtendOptionsQuery(ctx, field, q); next != nil { + q = next + } + } + return q +} + +func labelExpression(db *gorm.DB, table, column string) string { + return "COALESCE(CAST(" + quotedIdent(db, table) + "." + quotedIdent(db, column) + " AS TEXT), '')" +} + +// RelationOptions serves one page of a relation field's choices (D-17): +// scoped by RelationExtendOptionsQuery, searched case-insensitively on the +// label column, ordered by label then primary key. +func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController, field string, in RelationQuery) ([]RelationOption, ListMeta, error) { + if s.DB == nil { + return nil, ListMeta{}, errors.New("cabana: database is not configured") + } + if ctx == nil { + ctx = context.Background() + } + fr, ok := writableFieldRelation(cc, field) + if !ok { + return nil, ListMeta{}, recordNotFound{} + } + page, per, err := normalizeRelationPage(in.Page, in.PerPage) + if err != nil { + return nil, ListMeta{}, err + } + related := fr.Contract.NewRelated() + table := tableName(related) + pk := primaryColumn(related) + q := scopedRelationQuery(ctx, s.DB, cc, field, fr) + label := labelExpression(q, table, fr.LabelColumn) + if term := strings.TrimSpace(in.Search); term != "" { + q = q.Where(label+" ILIKE ? ESCAPE '\\'", "%"+escapeLike(term)+"%") + } + var total int64 + if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil { + return nil, ListMeta{}, lifecycleFailure(cc, err) + } + rows := make([]RelationOption, 0) + err = q.Select(quotedIdent(q, table) + "." + quotedIdent(q, pk) + " AS value, " + label + " AS label"). + Order(label). + Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}}). + Offset((page - 1) * per).Limit(per). + Scan(&rows).Error + if err != nil { + return nil, ListMeta{}, lifecycleFailure(cc, err) + } + last := 1 + if total > 0 { + last = int((total + int64(per) - 1) / int64(per)) + } + return rows, ListMeta{Page: page, PerPage: per, Total: total, LastPage: last}, nil +} + +// relationValue is one present, writable relation key lifted from a body. +type relationValue struct { + field string + fr *CompiledFieldRelation + ids []uint + null bool +} + +// liftRelationValues takes the writable relation keys present in body. Read +// only fields, fields outside the operation's context and absent keys are +// skipped. Shape errors are one validation_failed with every bad field. +func liftRelationValues(cc *CompiledController, body map[string]any, op string) ([]relationValue, error) { + if cc == nil || len(cc.FieldRelations) == 0 || body == nil { + return nil, nil + } + names := make([]string, 0, len(cc.FieldRelations)) + for name := range cc.FieldRelations { + names = append(names, name) + } + sort.Strings(names) + details := map[string]any{} + var out []relationValue + for _, name := range names { + fr := cc.FieldRelations[name] + if fr == nil || fr.ReadOnly || !contextAllows(cc, name, op) { + continue + } + raw, present := body[name] + if !present { + continue + } + value := relationValue{field: name, fr: fr} + if fr.Multiple { + items, ok := raw.([]any) + if !ok { + details[name] = []string{"The " + name + " field must be a list of ids."} + continue + } + seen := map[uint]struct{}{} + ids := make([]uint, 0, len(items)) + bad := "" + for _, item := range items { + id, err := relationID(item) + if err != nil { + bad = "The " + name + " field must be a list of integer ids." + break + } + if _, dup := seen[id]; dup { + bad = "The " + name + " field contains a duplicate id." + break + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if bad != "" { + details[name] = []string{bad} + continue + } + value.ids = ids + } else if raw == nil { + if !fr.Nullable { + details[name] = []string{"The " + name + " field cannot be empty."} + continue + } + value.null = true + } else { + id, err := relationID(raw) + if err != nil { + details[name] = []string{"The " + name + " field must be an integer id."} + continue + } + value.ids = []uint{id} + } + out = append(out, value) + } + if len(details) > 0 { + return nil, &ValidationError{Details: details} + } + return out, nil +} + +// relationID accepts a JSON integer only: no strings, booleans or fractions. +func relationID(v any) (uint, error) { + switch n := v.(type) { + case json.Number: + return asUint(n) + case float64: + if n != math.Trunc(n) { + return 0, errBadID + } + return asUint(n) + case int, int64, uint, uint32, uint64: + return asUint(n) + default: + return 0, errBadID + } +} + +// checkRelationScope re-runs the scoped options query for every submitted id +// inside the save transaction. An id the query does not return (unknown or +// out of scope) is validation_failed on that field and rolls the save back. +func checkRelationScope(ctx context.Context, tx *gorm.DB, cc *CompiledController, values []relationValue) error { + details := map[string]any{} + for _, value := range values { + if len(value.ids) == 0 { + continue + } + related := value.fr.Contract.NewRelated() + table := tableName(related) + pk := primaryColumn(related) + var found []uint + err := scopedRelationQuery(ctx, tx, cc, value.field, value.fr). + Where(clause.IN{Column: clause.Column{Table: table, Name: pk}, Values: uintValues(value.ids)}). + Pluck(quotedIdent(tx, table)+"."+quotedIdent(tx, pk), &found).Error + if err != nil { + return lifecycleFailure(cc, err) + } + have := map[uint]struct{}{} + for _, id := range found { + have[id] = struct{}{} + } + for _, id := range value.ids { + if _, ok := have[id]; !ok { + details[value.field] = []string{"The selected " + value.field + " is invalid."} + break + } + } + } + if len(details) > 0 { + return &ValidationError{Details: details} + } + return nil +} + +// assignBelongsTo writes validated belongsTo ids (or null) onto the parent's +// foreign key before the row write. Read-only keys never reach this point. +func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error { + for _, value := range values { + if value.fr.Multiple || value.fr.ReadOnly || protectedFillKey(value.fr.Contract.ForeignKey) { + continue + } + var id any + if !value.null { + id = value.ids[0] + } + if err := setModelColumn(model, value.fr.Contract.ForeignKey, id); err != nil { + return lifecycleFailure(cc, err) + } + } + return nil +} + +// syncBelongsToMany replaces the parent's pivot rows in submitted order: an +// explicit delete, then one bulk insert (Phase 5 join-table contract). +func syncBelongsToMany(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error { + parentPK := pkUint(model) + for _, value := range values { + if !value.fr.Multiple { + continue + } + c := value.fr.Contract + proto := c.NewPivot() + err := tx.WithContext(ctx).Unscoped(). + Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: parentPK}). + Delete(proto).Error + if err != nil { + return lifecycleFailure(cc, err) + } + if len(value.ids) == 0 { + continue + } + rows := reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf(proto).Elem()), 0, len(value.ids)) + for i, id := range value.ids { + pivot := c.NewPivot() + if err := setModelColumn(pivot, c.ParentForeignKey, parentPK); err != nil { + return lifecycleFailure(cc, err) + } + if err := setModelColumn(pivot, c.RelatedForeignKey, id); err != nil { + return lifecycleFailure(cc, err) + } + if c.OrderColumn != "" { + if err := setModelColumn(pivot, c.OrderColumn, i); err != nil { + return lifecycleFailure(cc, err) + } + } + rows = reflect.Append(rows, reflect.ValueOf(pivot).Elem()) + } + holder := reflect.New(rows.Type()) + holder.Elem().Set(rows) + if err := tx.WithContext(ctx).Create(holder.Interface()).Error; err != nil { + return lifecycleFailure(cc, err) + } + } + return nil +} + +// projectRelationFields adds every relation field's value to data (belongsTo +// id or null; belongsToMany ids in pivot order, then related id) and returns +// the labels in the same order. Read-only fields are included. +func projectRelationFields(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, data map[string]any) (RecordMeta, error) { + meta := RecordMeta{Labels: map[string][]RelationOption{}} + if cc == nil || len(cc.FieldRelations) == 0 { + return meta, nil + } + v := reflect.ValueOf(model) + for v.Kind() == reflect.Pointer { + v = v.Elem() + } + for name, fr := range cc.FieldRelations { + c := fr.Contract + var ids []uint + if fr.Multiple { + ids = []uint{} + q := tx.WithContext(ctx).Model(c.NewPivot()). + Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: pkUint(model)}) + if c.OrderColumn != "" { + q = q.Order(clause.OrderByColumn{Column: clause.Column{Name: c.OrderColumn}}) + } + if err := q.Order(clause.OrderByColumn{Column: clause.Column{Name: c.RelatedForeignKey}}).Pluck(c.RelatedForeignKey, &ids).Error; err != nil { + return RecordMeta{}, lifecycleFailure(cc, err) + } + data[name] = ids + } else { + data[name] = nil + if id, ok := foreignKeyValue(v, c.ForeignKey); ok { + data[name] = id + ids = []uint{id} + } + } + labels, err := relationLabels(ctx, tx, fr, ids) + if err != nil { + return RecordMeta{}, lifecycleFailure(cc, err) + } + meta.Labels[name] = labels + } + return meta, nil +} + +func foreignKeyValue(v reflect.Value, column string) (uint, bool) { + field := fieldByColumn(v, column) + if !field.IsValid() { + return 0, false + } + for field.Kind() == reflect.Pointer { + if field.IsNil() { + return 0, false + } + field = field.Elem() + } + if !field.CanInterface() { + return 0, false + } + id, err := asUint(field.Interface()) + if err != nil || id == 0 { + return 0, false + } + return id, true +} + +func relationLabels(ctx context.Context, tx *gorm.DB, fr *CompiledFieldRelation, ids []uint) ([]RelationOption, error) { + out := make([]RelationOption, 0, len(ids)) + if len(ids) == 0 { + return out, nil + } + related := fr.Contract.NewRelated() + table := tableName(related) + pk := primaryColumn(related) + var rows []RelationOption + err := tx.WithContext(ctx).Model(related). + Select(quotedIdent(tx, table) + "." + quotedIdent(tx, pk) + " AS value, " + labelExpression(tx, table, fr.LabelColumn) + " AS label"). + Where(clause.IN{Column: clause.Column{Table: table, Name: pk}, Values: uintValues(ids)}). + Scan(&rows).Error + if err != nil { + return nil, err + } + byID := make(map[uint]string, len(rows)) + for _, row := range rows { + byID[row.Value] = row.Label + } + for _, id := range ids { + if label, ok := byID[id]; ok { + out = append(out, RelationOption{Value: id, Label: label}) + } + } + return out, nil +} + +// fieldOptions serves GET /{vendor}/{plugin}/{controller}/fields/{field}/options. +func (s *service) fieldOptions(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + field := r.PathValue("field") + if _, ok := writableFieldRelation(cc, field); !ok { + writeNotFound(w, r) + return + } + svc, err := s.crud() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + q := r.URL.Query() + rows, meta, err := svc.RelationOptions(r.Context(), cc, field, RelationQuery{Search: q.Get("search"), Page: q.Get("page"), PerPage: q.Get("per_page")}) + if err != nil { + writeCRUDError(w, err) + return + } + WriteData(w, http.StatusOK, rows, meta) + }) +} diff --git a/cabana/relation_field_test.go b/cabana/relation_field_test.go new file mode 100644 index 0000000..54e2e89 --- /dev/null +++ b/cabana/relation_field_test.go @@ -0,0 +1,732 @@ +package cabana + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + "time" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bouncer" + "gorm.io/gorm" +) + +const p10FormConfig = `name: records +form: ~/plugins/acme/demo/models/record/fields.yaml +modelClass: Record +` + +const p10ListConfig = `modelClass: Record +list: ~/plugins/acme/demo/models/record/columns.yaml +recordsPerPage: 20 +` + +const p10Columns = `columns: + name: + label: Name + searchable: true +` + +const p10Fields = `fields: + name: + label: Name + type: text + required: true + group: + label: Group + type: relation + nameFrom: title + emptyOption: None + tags: + label: Tags + type: relation + nameFrom: label + person: + label: Person + type: relation + nameFrom: username +` + +type p10Record struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + GroupID *uint `gorm:"column:group_id"` + UserID uint `gorm:"column:user_id"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (p10Record) TableName() string { return "cabana_p10_records" } +func (p10Record) Fillable() []string { return []string{"name"} } +func (p10Record) Rules() map[string]string { return map[string]string{"name": "required"} } +func (p10Group) TableName() string { return "cabana_p10_groups" } +func (p10Tag) TableName() string { return "cabana_p10_tags" } +func (p10RecordTag) TableName() string { return "cabana_p10_record_tags" } +func (p10Person) TableName() string { return "cabana_p10_people" } +func (p10Controller) ID() string { return "acme.demo.records" } +func (p10Controller) ModelName() string { return "Record" } +func (p10Controller) ConfigDir() string { return "controllers/records" } +func (p10Controller) NewRecord() any { return &p10Record{} } +func (c p10Controller) RequiredPermissions() []string { return c.perms } + +type p10Group struct { + ID uint `gorm:"column:id;primaryKey"` + Title string `gorm:"column:title"` + Scope string `gorm:"column:scope"` +} + +type p10Tag struct { + ID uint `gorm:"column:id;primaryKey"` + Label *string `gorm:"column:label"` + Scope string `gorm:"column:scope"` +} + +type p10RecordTag struct { + RecordID uint `gorm:"column:record_id;primaryKey"` + TagID uint `gorm:"column:tag_id;primaryKey"` + Position int `gorm:"column:position"` +} + +type p10Person struct { + ID uint `gorm:"column:id;primaryKey"` + Email string `gorm:"column:email"` +} + +// p10Controller serves the acme fixtures. Options are scoped to rows whose +// scope is "visible"; the person relation writes the protected user_id and is +// therefore read-only (D-26). +type p10Controller struct { + perms []string + mutate func([]FieldRelationContract) []FieldRelationContract +} + +func (c p10Controller) AdminFieldRelations() []FieldRelationContract { + out := []FieldRelationContract{ + {Field: "group", Kind: "belongsTo", NewRelated: func() any { return &p10Group{} }, ForeignKey: "group_id"}, + {Field: "tags", Kind: "belongsToMany", NewRelated: func() any { return &p10Tag{} }, NewPivot: func() any { return &p10RecordTag{} }, + ParentForeignKey: "record_id", RelatedForeignKey: "tag_id", OrderColumn: "position"}, + {Field: "person", Kind: "belongsTo", NewRelated: func() any { return &p10Person{} }, ForeignKey: "user_id", LabelColumn: "email"}, + } + if c.mutate != nil { + out = c.mutate(out) + } + return out +} + +func (p10Controller) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB { + switch field { + case "group", "tags": + return db.Where("scope = ?", "visible") + default: + return db.Where("1 = 0") + } +} + +func (p10Controller) FormBeforeCreate(ctx context.Context, model any) error { + record, ok := model.(*p10Record) + if !ok { + return fmt.Errorf("unexpected model %T", model) + } + principal, _ := bouncer.User(ctx) + if principal != nil { + record.UserID = principal.ID + } + return nil +} + +func p10FS() fstest.MapFS { + return fstest.MapFS{ + "controllers/records/config_list.yaml": &fstest.MapFile{Data: []byte(p10ListConfig)}, + "controllers/records/config_form.yaml": &fstest.MapFile{Data: []byte(p10FormConfig)}, + "models/record/columns.yaml": &fstest.MapFile{Data: []byte(p10Columns)}, + "models/record/fields.yaml": &fstest.MapFile{Data: []byte(p10Fields)}, + } +} + +func p10Compile(ctl p10Controller) (*Registry, error) { + return compileRegistry([]controllerRef{{plugin: formPlugin{fsys: p10FS()}, ctl: ctl}}) +} + +type p10Seed struct { + groups map[string]uint + tags map[string]uint + person uint +} + +func p10Fixture(t *testing.T) (*service, *gorm.DB, p10Seed) { + t.Helper() + _, db := newListService(t) + models := []any{&p10Record{}, &p10Group{}, &p10Tag{}, &p10RecordTag{}, &p10Person{}} + if err := db.Migrator().DropTable(models...); err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(models...); err != nil { + t.Fatal(err) + } + reg, err := p10Compile(p10Controller{perms: []string{"acme.demo.access"}}) + if err != nil { + t.Fatalf("registry: %v", err) + } + app := backpack.New(nil) + if err := app.Publish(db); err != nil { + t.Fatal(err) + } + seed := p10Seed{groups: map[string]uint{}, tags: map[string]uint{}} + for _, g := range []p10Group{{Title: "Alpha", Scope: "visible"}, {Title: "Beta", Scope: "visible"}, {Title: "Hidden", Scope: "hidden"}} { + if err := db.Create(&g).Error; err != nil { + t.Fatal(err) + } + seed.groups[g.Title] = g.ID + } + for _, spec := range []struct{ label, scope string }{{"one", "visible"}, {"two", "visible"}, {"three", "visible"}, {"hidden", "hidden"}} { + label := spec.label + tag := p10Tag{Label: &label, Scope: spec.scope} + if err := db.Create(&tag).Error; err != nil { + t.Fatal(err) + } + seed.tags[spec.label] = tag.ID + } + person := p10Person{ID: 1, Email: "admin@acme.test"} + if err := db.Create(&person).Error; err != nil { + t.Fatal(err) + } + seed.person = person.ID + return &service{app: app, reg: reg}, db, seed +} + +func p10Principal(granted bool) *bouncer.Principal { + p := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{}} + if granted { + p.PermissionGrants["acme.demo.access"] = true + } + return p +} + +func p10Request(method, id, field, query string, body any, principal *bouncer.Principal) *http.Request { + var reader *bytes.Reader + if body != nil { + raw, _ := json.Marshal(body) + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, "/", reader) + req.URL.RawQuery = query + req.SetPathValue("vendor", "acme") + req.SetPathValue("plugin", "demo") + req.SetPathValue("controller", "records") + if id != "" { + req.SetPathValue("id", id) + } + if field != "" { + req.SetPathValue("field", field) + } + if principal != nil { + req = req.WithContext(bouncer.WithUser(req.Context(), principal)) + } + return req +} + +func p10Save(svc *service, method, id string, body any) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := p10Request(method, id, "", "", body, p10Principal(true)) + switch method { + case http.MethodPost: + svc.create(rec, req) + case http.MethodPut: + svc.update(rec, req) + case http.MethodGet: + svc.show(rec, req) + } + return rec +} + +func p10Options(svc *service, field, query string, principal *bouncer.Principal) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + svc.fieldOptions(rec, p10Request(http.MethodGet, "", field, query, nil, principal)) + return rec +} + +type p10Envelope struct { + Data map[string]any `json:"data"` + Meta struct { + Labels map[string][]RelationOption `json:"labels"` + } `json:"meta"` +} + +func p10Decode(t *testing.T, rec *httptest.ResponseRecorder, status int) p10Envelope { + t.Helper() + if rec.Code != status { + t.Fatalf("status=%d want %d body=%s", rec.Code, status, rec.Body.String()) + } + var body p10Envelope + dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())) + dec.UseNumber() + if err := dec.Decode(&body); err != nil { + t.Fatalf("decode %s: %v", rec.Body.String(), err) + } + if body.Meta.Labels == nil { + t.Fatalf("meta.labels missing: %s", rec.Body.String()) + } + return body +} + +func p10ID(v any) uint { + n, ok := v.(json.Number) + if !ok { + return 0 + } + i, err := n.Int64() + if err != nil || i < 0 { + return 0 + } + return uint(i) +} + +func p10IDs(v any) []uint { + items, ok := v.([]any) + if !ok { + return nil + } + out := make([]uint, 0, len(items)) + for _, item := range items { + out = append(out, p10ID(item)) + } + return out +} + +func p10Pivot(t *testing.T, db *gorm.DB, recordID uint) []uint { + t.Helper() + var rows []p10RecordTag + if err := db.Where("record_id = ?", recordID).Order("position, tag_id").Find(&rows).Error; err != nil { + t.Fatal(err) + } + out := make([]uint, len(rows)) + for i, row := range rows { + if row.Position != i { + t.Fatalf("pivot positions=%+v", rows) + } + out[i] = row.TagID + } + return out +} + +func p10Stored(t *testing.T, db *gorm.DB, id uint) p10Record { + t.Helper() + var row p10Record + if err := db.First(&row, id).Error; err != nil { + t.Fatal(err) + } + return row +} + +func sameUintSeq(got, want []uint) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +func TestPhase10RelationOptions(t *testing.T) { + svc, db, seed := p10Fixture(t) + extra := []struct{ label, scope string }{ + {"alpha first", "visible"}, {"ALPHA second", "visible"}, {"alpha hidden", "hidden"}, + {"100%_off", "visible"}, {"100 off", "visible"}, {"same", "visible"}, {"same", "visible"}, + } + ids := map[string][]uint{} + for _, spec := range extra { + label := spec.label + tag := p10Tag{Label: &label, Scope: spec.scope} + if err := db.Create(&tag).Error; err != nil { + t.Fatal(err) + } + ids[spec.label] = append(ids[spec.label], tag.ID) + } + for i := 0; i < 25; i++ { + label := fmt.Sprintf("bulk %02d", i) + if err := db.Create(&p10Tag{Label: &label, Scope: "visible"}).Error; err != nil { + t.Fatal(err) + } + } + if err := db.Create(&p10Tag{Scope: "visible"}).Error; err != nil { + t.Fatal(err) + } + visible := int64(3 + 6 + 25 + 1) + + decode := func(rec *httptest.ResponseRecorder) ([]RelationOption, ListMeta) { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data []RelationOption `json:"data"` + Meta ListMeta `json:"meta"` + } + dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())) + dec.DisallowUnknownFields() + if err := dec.Decode(&body); err != nil { + t.Fatalf("decode %s: %v", rec.Body.String(), err) + } + return body.Data, body.Meta + } + + all := p10Options(svc, "tags", "", p10Principal(true)) + rows, meta := decode(all) + if meta.Page != 1 || meta.PerPage != 20 || meta.Total != visible || meta.LastPage != 2 || len(rows) != 20 { + t.Fatalf("default page meta=%+v rows=%d", meta, len(rows)) + } + if !strings.Contains(all.Body.String(), `"value":`) || strings.Contains(all.Body.String(), `"value":"`) { + t.Fatalf("option values are not numbers: %s", all.Body.String()) + } + // Label order follows the database collation; the "bulk NN" labels sort + // the same under every collation. + bulk, _ := decode(p10Options(svc, "tags", "search=bulk&per_page=100", p10Principal(true))) + if len(bulk) != 25 { + t.Fatalf("bulk rows=%d", len(bulk)) + } + for i, row := range bulk { + if row.Label != fmt.Sprintf("bulk %02d", i) { + t.Fatalf("options not ordered by label: %+v", bulk) + } + } + if rows[0].Label != "" { + t.Fatalf("a null label must sort first as an empty string: %+v", rows[0]) + } + for _, row := range rows { + if strings.Contains(row.Label, "hidden") { + t.Fatalf("scoped options leaked a hidden row: %+v", rows) + } + } + page2, meta2 := decode(p10Options(svc, "tags", "page=2", p10Principal(true))) + if meta2.Page != 2 || len(page2) != int(visible)-20 { + t.Fatalf("page 2 meta=%+v rows=%d", meta2, len(page2)) + } + big, bigMeta := decode(p10Options(svc, "tags", "per_page=100", p10Principal(true))) + if bigMeta.PerPage != 100 || len(big) != int(visible) || bigMeta.LastPage != 1 { + t.Fatalf("per_page=100 meta=%+v rows=%d", bigMeta, len(big)) + } + + caseless, _ := decode(p10Options(svc, "tags", "search=Alpha", p10Principal(true))) + if len(caseless) != 2 || caseless[0].Value != ids["ALPHA second"][0] && caseless[0].Value != ids["alpha first"][0] { + t.Fatalf("case-insensitive search=%+v", caseless) + } + literal, _ := decode(p10Options(svc, "tags", "search=%25_", p10Principal(true))) + if len(literal) != 1 || literal[0].Label != "100%_off" { + t.Fatalf("LIKE metacharacters were not escaped: %+v", literal) + } + same, _ := decode(p10Options(svc, "tags", "search=same", p10Principal(true))) + if len(same) != 2 || same[0].Value != ids["same"][0] || same[1].Value != ids["same"][1] { + t.Fatalf("equal labels not ordered by id: %+v want %v", same, ids["same"]) + } + groups, _ := decode(p10Options(svc, "group", "", p10Principal(true))) + if len(groups) != 2 || groups[0].Value != seed.groups["Alpha"] || groups[0].Label != "Alpha" || groups[1].Label != "Beta" { + t.Fatalf("group options=%+v", groups) + } + + for _, tc := range []struct{ query, field string }{ + {"per_page=101", "per_page"}, {"per_page=0", "per_page"}, {"page=0", "page"}, {"page=x", "page"}, + } { + rec := p10Options(svc, "tags", tc.query, p10Principal(true)) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+tc.field+`"`) { + t.Fatalf("%s status=%d body=%s", tc.query, rec.Code, rec.Body.String()) + } + } + for _, field := range []string{"name", "person", "nope", "Tags"} { + rec := p10Options(svc, field, "", p10Principal(true)) + if rec.Code != http.StatusNotFound { + t.Fatalf("field %s status=%d body=%s", field, rec.Code, rec.Body.String()) + } + assertErrorCode(t, rec.Body.Bytes(), "not_found") + if strings.Contains(rec.Body.String(), "admin@acme.test") { + t.Fatalf("read-only options disclosed a label: %s", rec.Body.String()) + } + } + + // Permission is checked before any SQL: this service has no database, so + // reaching a query would be a 500, not a 403. + denied := &service{reg: svc.reg} + rec := p10Options(denied, "tags", "search=one", p10Principal(false)) + if rec.Code != http.StatusForbidden { + t.Fatalf("denied status=%d body=%s", rec.Code, rec.Body.String()) + } + assertErrorCode(t, rec.Body.Bytes(), "forbidden") + frontend := p10Principal(true) + frontend.Backend = false + rec = httptest.NewRecorder() + req := p10Request(http.MethodGet, "", "tags", "", nil, nil) + req = req.WithContext(bouncer.WithUser(req.Context(), frontend)) + denied.fieldOptions(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("frontend principal status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestPhase10RelationSave(t *testing.T) { + svc, db, seed := p10Fixture(t) + one, two, three := seed.tags["one"], seed.tags["two"], seed.tags["three"] + alpha, beta := seed.groups["Alpha"], seed.groups["Beta"] + + created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{ + "name": "record", "group": alpha, "tags": []uint{three, one}, "person": 99, "user_id": 99, + }), http.StatusCreated) + id := p10ID(created.Data["id"]) + if p10ID(created.Data["group"]) != alpha || !sameUintSeq(p10IDs(created.Data["tags"]), []uint{three, one}) { + t.Fatalf("created data=%v", created.Data) + } + if p10ID(created.Data["person"]) != seed.person { + t.Fatalf("read-only person value=%v want %d", created.Data["person"], seed.person) + } + if _, leaked := created.Data["user_id"]; leaked { + t.Fatalf("protected foreign key leaked: %v", created.Data) + } + labels := created.Meta.Labels + if len(labels["group"]) != 1 || labels["group"][0] != (RelationOption{Value: alpha, Label: "Alpha"}) { + t.Fatalf("group labels=%+v", labels["group"]) + } + if len(labels["tags"]) != 2 || labels["tags"][0] != (RelationOption{Value: three, Label: "three"}) || labels["tags"][1].Value != one { + t.Fatalf("tag labels=%+v", labels["tags"]) + } + if len(labels["person"]) != 1 || labels["person"][0] != (RelationOption{Value: seed.person, Label: "admin@acme.test"}) { + t.Fatalf("person labels=%+v", labels["person"]) + } + stored := p10Stored(t, db, id) + if stored.GroupID == nil || *stored.GroupID != alpha || stored.UserID != seed.person { + t.Fatalf("stored=%+v", stored) + } + if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{three, one}) { + t.Fatalf("pivot=%v", got) + } + + idText := fmt.Sprintf("%d", id) + p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed"}), http.StatusOK) + stored = p10Stored(t, db, id) + if stored.Name != "renamed" || stored.GroupID == nil || *stored.GroupID != alpha { + t.Fatalf("absent keys changed the relation: %+v", stored) + } + if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{three, one}) { + t.Fatalf("absent key changed the pivot: %v", got) + } + + cleared := p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "group": nil, "tags": []uint{two, one}}), http.StatusOK) + if cleared.Data["group"] != nil || len(cleared.Meta.Labels["group"]) != 0 || cleared.Meta.Labels["group"] == nil { + t.Fatalf("cleared group data=%v labels=%v", cleared.Data["group"], cleared.Meta.Labels) + } + if stored := p10Stored(t, db, id); stored.GroupID != nil { + t.Fatalf("null did not clear group_id: %+v", stored) + } + if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{two, one}) { + t.Fatalf("replaced pivot=%v", got) + } + + p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "group": beta}), http.StatusOK) + shown := p10Decode(t, p10Save(svc, http.MethodGet, idText, nil), http.StatusOK) + if p10ID(shown.Data["group"]) != beta || !sameUintSeq(p10IDs(shown.Data["tags"]), []uint{two, one}) { + t.Fatalf("show data=%v", shown.Data) + } + if len(shown.Meta.Labels["tags"]) != 2 || shown.Meta.Labels["tags"][0].Label != "two" || shown.Meta.Labels["group"][0].Label != "Beta" { + t.Fatalf("show labels=%+v", shown.Meta.Labels) + } + + emptied := p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "tags": []uint{}}), http.StatusOK) + if tags, ok := emptied.Data["tags"].([]any); !ok || len(tags) != 0 { + t.Fatalf("emptied tags=%#v", emptied.Data["tags"]) + } + if got := p10Pivot(t, db, id); len(got) != 0 { + t.Fatalf("empty list left pivot rows %v", got) + } +} + +func TestPhase10RelationForgedID(t *testing.T) { + svc, db, seed := p10Fixture(t) + one := seed.tags["one"] + created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{"name": "keep", "group": seed.groups["Alpha"], "tags": []uint{one}}), http.StatusCreated) + id := p10ID(created.Data["id"]) + idText := fmt.Sprintf("%d", id) + + for _, tc := range []struct { + name string + body map[string]any + field string + }{ + {"out of scope tag", map[string]any{"tags": []uint{seed.tags["hidden"]}}, "tags"}, + {"unknown tag", map[string]any{"tags": []uint{999999}}, "tags"}, + {"text tag", map[string]any{"tags": []any{"x"}}, "tags"}, + {"fractional tag", map[string]any{"tags": []any{1.5}}, "tags"}, + {"negative tag", map[string]any{"tags": []any{-1}}, "tags"}, + {"duplicate tag", map[string]any{"tags": []uint{one, one}}, "tags"}, + {"scalar for many", map[string]any{"tags": one}, "tags"}, + {"out of scope group", map[string]any{"group": seed.groups["Hidden"]}, "group"}, + {"list for one", map[string]any{"group": []uint{seed.groups["Beta"]}}, "group"}, + {"text group", map[string]any{"group": "abc"}, "group"}, + } { + t.Run(tc.name, func(t *testing.T) { + body := map[string]any{"name": "changed"} + for key, value := range tc.body { + body[key] = value + } + rec := p10Save(svc, http.MethodPut, idText, body) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + assertErrorCode(t, rec.Body.Bytes(), "validation_failed") + var envelope struct { + Error struct { + Details map[string][]string `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if len(envelope.Error.Details[tc.field]) == 0 { + t.Fatalf("details missing %s: %s", tc.field, rec.Body.String()) + } + stored := p10Stored(t, db, id) + if stored.Name != "keep" || stored.GroupID == nil || *stored.GroupID != seed.groups["Alpha"] { + t.Fatalf("rejected save committed: %+v", stored) + } + if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{one}) { + t.Fatalf("rejected save changed the pivot: %v", got) + } + }) + } + + var before int64 + if err := db.Model(&p10Record{}).Count(&before).Error; err != nil { + t.Fatal(err) + } + rec := p10Save(svc, http.MethodPost, "", map[string]any{"name": "forged", "tags": []uint{one, seed.tags["hidden"]}}) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("forged create status=%d body=%s", rec.Code, rec.Body.String()) + } + var after int64 + if err := db.Model(&p10Record{}).Count(&after).Error; err != nil { + t.Fatal(err) + } + var pivots int64 + if err := db.Model(&p10RecordTag{}).Count(&pivots).Error; err != nil { + t.Fatal(err) + } + if after != before || pivots != 1 { + t.Fatalf("forged create committed rows=%d->%d pivots=%d", before, after, pivots) + } +} + +func TestPhase10RelationBoot(t *testing.T) { + reg, err := p10Compile(p10Controller{}) + if err != nil { + t.Fatalf("valid contracts: %v", err) + } + cc, _ := reg.Get("acme.demo.records") + raw, err := json.Marshal(cc.Form.Fields) + if err != nil { + t.Fatal(err) + } + fields := map[string]map[string]any{} + var list []map[string]any + if err := json.Unmarshal(raw, &list); err != nil { + t.Fatal(err) + } + for _, field := range list { + fields[field["name"].(string)] = field + } + if fields["tags"]["multiple"] != true || fields["tags"]["readOnly"] != nil { + t.Fatalf("tags field=%v", fields["tags"]) + } + if fields["group"]["multiple"] != nil || fields["group"]["readOnly"] != nil { + t.Fatalf("group field=%v", fields["group"]) + } + if fields["person"]["readOnly"] != true || fields["person"]["multiple"] != nil { + t.Fatalf("person field=%v", fields["person"]) + } + + for _, tc := range []struct { + name string + mutate func([]FieldRelationContract) []FieldRelationContract + want []string + }{ + {"missing contract", func(in []FieldRelationContract) []FieldRelationContract { return in[1:] }, []string{"field group", "no relation contract"}}, + {"missing foreign key column", func(in []FieldRelationContract) []FieldRelationContract { + in[0].ForeignKey = "missing_id" + return in + }, []string{"field group", "missing_id"}}, + {"missing pivot column", func(in []FieldRelationContract) []FieldRelationContract { + in[1].OrderColumn = "rank" + return in + }, []string{"field tags", "rank"}}, + {"missing label column", func(in []FieldRelationContract) []FieldRelationContract { + in[2].LabelColumn = "username" + return in + }, []string{"field person", "username"}}, + {"unknown kind", func(in []FieldRelationContract) []FieldRelationContract { + in[0].Kind = "hasMany" + return in + }, []string{"field group", "unknown relation kind hasMany"}}, + {"missing related model", func(in []FieldRelationContract) []FieldRelationContract { + in[0].NewRelated = nil + return in + }, []string{"field group", "related model"}}, + {"duplicate contract", func(in []FieldRelationContract) []FieldRelationContract { return append(in, in[0]) }, []string{"field group", "duplicate"}}, + {"orphan contract", func(in []FieldRelationContract) []FieldRelationContract { + return append(in, FieldRelationContract{Field: "extra", Kind: "belongsTo", NewRelated: func() any { return &p10Group{} }, ForeignKey: "group_id"}) + }, []string{"field extra", "not a relation field"}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := p10Compile(p10Controller{mutate: tc.mutate}) + if err == nil { + t.Fatal("activation accepted a broken relation contract") + } + for _, want := range append([]string{"acme.demo", "acme.demo.records"}, tc.want...) { + if !strings.Contains(err.Error(), want) { + t.Fatalf("err=%v missing %q", err, want) + } + } + }) + } + + _, err = compileRegistry([]controllerRef{{plugin: formPlugin{fsys: p10FS()}, ctl: crudControllerLike{}}}) + if err == nil || !strings.Contains(err.Error(), "field group") || !strings.Contains(err.Error(), "AdminFieldRelations") { + t.Fatalf("controller without contracts err=%v", err) + } +} + +// crudControllerLike has the relation form but declares no contracts. +type crudControllerLike struct{} + +func (crudControllerLike) ID() string { return "acme.demo.records" } +func (crudControllerLike) ModelName() string { return "Record" } +func (crudControllerLike) ConfigDir() string { return "controllers/records" } +func (crudControllerLike) NewRecord() any { return &p10Record{} } + +func TestPhase10NestedGetDispatch(t *testing.T) { + svc, _, seed := p10Fixture(t) + call := func(id, segment, name string) *httptest.ResponseRecorder { + req := p10Request(http.MethodGet, id, "", "", nil, p10Principal(true)) + req.SetPathValue("segment", segment) + req.SetPathValue("name", name) + rec := httptest.NewRecorder() + svc.nestedGet(rec, req) + return rec + } + options := call("fields", "group", "options") + if options.Code != http.StatusOK || !strings.Contains(options.Body.String(), fmt.Sprintf(`"value":%d`, seed.groups["Alpha"])) { + t.Fatalf("fields dispatch status=%d body=%s", options.Code, options.Body.String()) + } + for _, tc := range [][3]string{{"fields", "group", "choices"}, {"5", "other", "x"}, {"filters", "group", "list"}} { + rec := call(tc[0], tc[1], tc[2]) + if rec.Code != http.StatusNotFound { + t.Fatalf("%v status=%d body=%s", tc, rec.Code, rec.Body.String()) + } + assertErrorCode(t, rec.Body.Bytes(), "not_found") + } +} diff --git a/cabana/schema_types.go b/cabana/schema_types.go index 47a964d..07b6bbe 100644 --- a/cabana/schema_types.go +++ b/cabana/schema_types.go @@ -130,6 +130,8 @@ type FormField struct { NameFrom string `json:"nameFrom,omitempty"` EmptyOption string `json:"emptyOption,omitempty"` Relation string `json:"relation,omitempty"` + Multiple bool `json:"multiple,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` Required bool `json:"required,omitempty"` Default *jsonScalar `json:"default,omitempty"` Attributes map[string]jsonScalar `json:"attributes,omitempty"` diff --git a/cabana/security_coverage_test.go b/cabana/security_coverage_test.go index 4edbe64..1f3eec6 100644 --- a/cabana/security_coverage_test.go +++ b/cabana/security_coverage_test.go @@ -12,46 +12,61 @@ import ( "git.golem15.com/golem15/summercms/pact" ) -// phase09Routes is the admin surface mounted by service.mount. API keys are -// method plus the path relative to {backend.uri}/api/v1 (D-03); spa entries -// are the public SPA shell routes relative to {backend.uri} and are not part -// of the OpenAPI inventory. A handler added outside this set, or a protected -// handler missing the backend guard, fails TestPhase09PermissionMatrix. -var phase09Routes = []struct { - key string - public bool - spa bool -}{ - {"POST /auth/login", true, false}, - {"POST /auth/refresh", true, false}, - {"POST /auth/logout", false, false}, - {"GET /auth/me", false, false}, - {"GET /navigation", false, false}, - {"GET /settings", false, false}, - {"GET /settings/{code}/schema", false, false}, - {"GET /settings/{code}", false, false}, - {"PUT /settings/{code}", false, false}, - {"GET /{vendor}/{plugin}/{controller}/schema/list", false, false}, - {"GET /{vendor}/{plugin}/{controller}/schema/form", false, false}, - {"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", false, false}, - {"GET /{vendor}/{plugin}/{controller}", false, false}, - {"POST /{vendor}/{plugin}/{controller}", false, false}, - {"POST /{vendor}/{plugin}/{controller}/bulk-delete", false, false}, - {"GET /{vendor}/{plugin}/{controller}/{id}", false, false}, - {"PUT /{vendor}/{plugin}/{controller}/{id}", false, false}, - {"DELETE /{vendor}/{plugin}/{controller}/{id}", false, false}, - {"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", false, false}, - {"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false, false}, - {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false, false}, - {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false, false}, - {"GET ", true, true}, - {"GET /{path...}", true, true}, +// adminRoute is one logical admin route. key is method plus the path relative +// to {backend.uri}/api/v1 (D-03); spa entries are the public SPA shell routes +// relative to {backend.uri} and are not part of the OpenAPI inventory. +// mounted, when set, is the relative ServeMux key that serves the route: +// logical routes ServeMux cannot hold side by side share one dispatching +// pattern (service.nestedGet). +type adminRoute struct { + key string + public bool + spa bool + mounted string +} + +// nestedGetRoute is the shared six-segment GET pattern. +const nestedGetRoute = "GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name}" + +// phase09Routes is the admin surface mounted by service.mount. A handler added +// outside this set, or a protected handler missing the backend guard, fails +// TestPhase09PermissionMatrix. +var phase09Routes = []adminRoute{ + {key: "POST /auth/login", public: true}, + {key: "POST /auth/refresh", public: true}, + {key: "POST /auth/logout"}, + {key: "GET /auth/me"}, + {key: "GET /navigation"}, + {key: "GET /settings"}, + {key: "GET /settings/{code}/schema"}, + {key: "GET /settings/{code}"}, + {key: "PUT /settings/{code}"}, + {key: "GET /{vendor}/{plugin}/{controller}/schema/list"}, + {key: "GET /{vendor}/{plugin}/{controller}/schema/form"}, + {key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"}, + {key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options", mounted: nestedGetRoute}, + {key: "GET /{vendor}/{plugin}/{controller}"}, + {key: "POST /{vendor}/{plugin}/{controller}"}, + {key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"}, + {key: "GET /{vendor}/{plugin}/{controller}/{id}"}, + {key: "PUT /{vendor}/{plugin}/{controller}/{id}"}, + {key: "DELETE /{vendor}/{plugin}/{controller}/{id}"}, + {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", mounted: nestedGetRoute}, + {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates"}, + {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"}, + {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"}, + {key: "GET ", public: true, spa: true}, + {key: "GET /{path...}", public: true, spa: true}, } // mountedKey is the full mounted route key for an inventory entry. -func mountedKey(key string, spa bool) string { +func mountedKey(route adminRoute) string { + key := route.key + if route.mounted != "" { + key = route.mounted + } method, rel, _ := strings.Cut(key, " ") - if spa { + if route.spa { return method + " " + DefaultAdminPrefix + rel } return method + " " + adminAPI(rel) @@ -67,11 +82,15 @@ func TestPhase09PermissionMatrix(t *testing.T) { } got[key] = router.middleware[key] } - if len(got) != len(phase09Routes) { - t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(phase09Routes), router.routes) + want := map[string]bool{} + for _, route := range phase09Routes { + want[mountedKey(route)] = true + } + if len(got) != len(want) { + t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(want), router.routes) } for _, route := range phase09Routes { - key := mountedKey(route.key, route.spa) + key := mountedKey(route) mw, ok := got[key] if !ok { t.Fatalf("missing mounted route %s in %v", key, router.routes) @@ -250,6 +269,8 @@ func phase09ProtectedCalls() []phase09Call { {"form-schema", (*service).formSchema}, {"relation-schema", (*service).relationSchema}, {"relation-linked", (*service).relationLinked}, + {"field-options", (*service).fieldOptions}, + {"nested-get", (*service).nestedGet}, {"relation-candidates", (*service).relationCandidates}, {"relation-link", (*service).relationLink}, {"relation-unlink", (*service).relationUnlink}, @@ -278,6 +299,7 @@ func phase09Request(principal *bouncer.Principal) *http.Request { req.SetPathValue("controller", "widgets") req.SetPathValue("id", "1") req.SetPathValue("name", "editors") + req.SetPathValue("segment", "relations") req.SetPathValue("code", "demo") if principal != nil { req = req.WithContext(bouncer.WithUser(req.Context(), principal)) diff --git a/pact/capabilities.go b/pact/capabilities.go index d8b0fd7..aa139d2 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -242,6 +242,13 @@ type RelationExtendManageQuery interface { RelationExtendManageQuery(ctx context.Context, relation string, db *gorm.DB) *gorm.DB } +// RelationExtendOptionsQuery optionally narrows the rows a form relation +// field offers (D-17). The same scoped query revalidates submitted ids on +// save, so a row it does not return cannot be attached (D-18). +type RelationExtendOptionsQuery interface { + RelationExtendOptionsQuery(ctx context.Context, field string, db *gorm.DB) *gorm.DB +} + // RelationBeforeLink optionally stamps pivot columns before a link insert. type RelationBeforeLink interface { RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error