diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index ef04f21..6472fc1 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -137,6 +137,24 @@ "additionalProperties": {}, "type": "object" }, + "cabana.AdminRelationLinkRequest": { + "properties": { + "ids": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "pivot": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, "cabana.AdminRoleSummary": { "properties": { "code": { @@ -334,6 +352,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_AdminRecord": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.AdminRecord" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_BulkResult": { "properties": { "data": { @@ -5082,8 +5115,9 @@ ] } }, - "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link": { + "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete": { "post": { + "description": "Deletes children of the owner through their model: a hasMany child is deleted (hooks and soft delete run); a belongsToMany record loses this owner's pivot row and is then deleted. Every id must be a child of this owner, otherwise the whole request is 404 and nothing is deleted. The view panel must declare the delete toolbar button, otherwise 403.", "parameters": [ { "description": "Vendor", @@ -5142,6 +5176,139 @@ "description": "Related record ids", "required": true }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_BulkResult" + } + } + }, + "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" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Request Entity Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Delete related records", + "tags": [ + "admin" + ] + } + }, + "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link": { + "post": { + "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": "Owner id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Relation name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminRelationLinkRequest" + } + } + }, + "description": "Related record ids and optional pivot form values", + "required": true + }, "responses": { "200": { "content": { @@ -5205,6 +5372,269 @@ ] } }, + "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}": { + "get": { + "description": "The pivot form (pivot.form) values of the pivot row linking the owner and one related record, keyed by field name; id is the related record's id. Needs a pivot form and the link or update toolbar button (403 otherwise); a record not linked to this owner is 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": "Owner id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Relation name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Related record id", + "in": "path", + "name": "child", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_AdminRecord" + } + } + }, + "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": "Show the pivot values of a link", + "tags": [ + "admin" + ] + }, + "put": { + "description": "Saves pivot form values on the pivot row linking the owner and one related record. Only pivot form fields are accepted (422 per unknown key); the pivot foreign keys, timestamps and hook columns can never be set. Gated and scoped like the pivot show route.", + "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": "Owner id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Relation name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Related record id", + "in": "path", + "name": "child", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminRecord" + } + } + }, + "description": "Pivot form values keyed by field name", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_AdminRecord" + } + } + }, + "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" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Request Entity Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Update the pivot values of a link", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": { "post": { "description": "Creates a record through the relation's manage form (manage.form, or the top-level form of config_relation.yaml) and attaches it to the owner: a hasMany child gets the owner's key in its foreign key (the server sets it; the body cannot), a belongsToMany record gets a pivot row. The view panel must declare the create toolbar button, otherwise 403. The owner is scoped like the record show route.", @@ -5339,6 +5769,269 @@ ] } }, + "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}": { + "get": { + "description": "One child of the owner, projected through the manage form when the relation declares the update button, else through the view form (view.form, or the top-level form). A record that is not a child of this owner (hasMany: its foreign key; belongsToMany: a pivot row) is 404. Without either form the route answers 403.", + "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": "Owner id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Relation name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Related record id", + "in": "path", + "name": "child", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.RecordEnvelope" + } + } + }, + "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": "Show a related record", + "tags": [ + "admin" + ] + }, + "put": { + "description": "Saves one child of the owner through the manage form, with the related model's rules and hooks. The view panel must declare the update toolbar button, otherwise 403. A record that is not a child of this owner is 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": "Owner id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "description": "Relation name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Related record id", + "in": "path", + "name": "child", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminRecord" + } + } + }, + "description": "Field values of the manage form keyed by field name", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.RecordEnvelope" + } + } + }, + "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" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Request Entity Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Update a related record", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": { "post": { "parameters": [ diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 8268325..39f3dba 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -2540,6 +2540,106 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Delete related records + * @description Deletes children of the owner through their model: a hasMany child is deleted (hooks and soft delete run); a belongsToMany record loses this owner's pivot row and is then deleted. Every id must be a child of this owner, otherwise the whole request is 404 and nothing is deleted. The view panel must declare the delete toolbar button, otherwise 403. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Owner id */ + id: number; + /** @description Relation name */ + name: string; + }; + cookie?: never; + }; + /** @description Related record ids */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminIDsRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_BulkResult"]; + }; + }; + /** @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 Request Entity Too Large */ + 413: { + 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"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link": { parameters: { query?: never; @@ -2568,10 +2668,10 @@ export interface paths { }; cookie?: never; }; - /** @description Related record ids */ + /** @description Related record ids and optional pivot form values */ requestBody: { content: { - "application/json": components["schemas"]["cabana.AdminIDsRequest"]; + "application/json": components["schemas"]["cabana.AdminRelationLinkRequest"]; }; }; responses: { @@ -2628,6 +2728,180 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Show the pivot values of a link + * @description The pivot form (pivot.form) values of the pivot row linking the owner and one related record, keyed by field name; id is the related record's id. Needs a pivot form and the link or update toolbar button (403 otherwise); a record not linked to this owner is 404. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Owner id */ + id: number; + /** @description Relation name */ + name: string; + /** @description Related record id */ + child: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_AdminRecord"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + /** + * Update the pivot values of a link + * @description Saves pivot form values on the pivot row linking the owner and one related record. Only pivot form fields are accepted (422 per unknown key); the pivot foreign keys, timestamps and hook columns can never be set. Gated and scoped like the pivot show route. + */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Owner id */ + id: number; + /** @description Relation name */ + name: string; + /** @description Related record id */ + child: number; + }; + cookie?: never; + }; + /** @description Pivot form values keyed by field name */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminRecord"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_AdminRecord"]; + }; + }; + /** @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 Request Entity Too Large */ + 413: { + 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"]; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": { parameters: { query?: never; @@ -2728,6 +3002,180 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Show a related record + * @description One child of the owner, projected through the manage form when the relation declares the update button, else through the view form (view.form, or the top-level form). A record that is not a child of this owner (hasMany: its foreign key; belongsToMany: a pivot row) is 404. Without either form the route answers 403. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Owner id */ + id: number; + /** @description Relation name */ + name: string; + /** @description Related record id */ + child: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.RecordEnvelope"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + /** + * Update a related record + * @description Saves one child of the owner through the manage form, with the related model's rules and hooks. The view panel must declare the update toolbar button, otherwise 403. A record that is not a child of this owner is 404. + */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Owner id */ + id: number; + /** @description Relation name */ + name: string; + /** @description Related record id */ + child: number; + }; + cookie?: never; + }; + /** @description Field values of the manage form keyed by field name */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminRecord"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.RecordEnvelope"]; + }; + }; + /** @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 Request Entity Too Large */ + 413: { + 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"]; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": { parameters: { query?: never; @@ -2864,6 +3312,12 @@ export interface components { "cabana.AdminRecord": { [key: string]: unknown; }; + "cabana.AdminRelationLinkRequest": { + ids: number[]; + pivot?: { + [key: string]: unknown; + }; + }; "cabana.AdminRoleSummary": { code: string; id: number; @@ -2912,6 +3366,10 @@ export interface components { data: components["schemas"]["cabana.AdminProfile"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_AdminRecord": { + data: components["schemas"]["cabana.AdminRecord"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_BulkResult": { data: components["schemas"]["cabana.BulkResult"]; meta: components["schemas"]["cabana.SuccessMeta"]; diff --git a/docs/backend/relation-manager.md b/docs/backend/relation-manager.md index 0b16d5b..13da923 100644 --- a/docs/backend/relation-manager.md +++ b/docs/backend/relation-manager.md @@ -117,7 +117,39 @@ The `manage` panel may declare only `link`. An unknown or duplicate button stops `POST .../{id}/relations/{name}/records` creates a related record from the manage form's fields and attaches it to the parent, in one transaction. The parent is loaded through `pact.FormExtendQuery`, so a parent the administrator cannot open answers 404. The child is filled and validated like a controller save (the related model's `Fill`, its rules merged with the form's `required` flags, a 422 `validation_failed` envelope on failure) and its GORM hooks run. On a hasMany the server sets the `ForeignKey` to the parent's key; a body that names that column cannot change it. On a belongsToMany the record is inserted and its pivot row written, with `pact.RelationBeforeLink` stamping the hook columns. -A controller can hook into the child writes with the optional `pact.RelationBeforeCreate` and `pact.RelationAfterCreate` (and the update and delete pairs), each called with the relation name, the parent and the child. A hook error rolls the whole write back and answers the generic lifecycle error. +### Editing, deleting, linking and unlinking + +| Route | Button | Effect | +|-------|--------|--------| +| GET `.../{id}/relations/{name}/records/{child}` | `update`, or a view form | One child, through the manage form when `update` is listed, else through the view form. | +| PUT `.../{id}/relations/{name}/records/{child}` | `update` | Saves the child through the manage form, with the related model's rules and hooks. | +| POST `.../{id}/relations/{name}/delete` | `delete` | `{ids}`: deletes the children. | +| POST `.../{id}/relations/{name}/link` | `link` | `{ids}`, optionally with `pivot`: links existing records. | +| POST `.../{id}/relations/{name}/unlink` | `unlink` | `{ids}`: unlinks records. | + +What delete, link and unlink do depends on the kind: + +- On a hasMany, `delete` deletes the child through its model, so its hooks and soft delete run. `link` adopts records whose `ForeignKey` is NULL, setting it to the parent's key through the child model; `unlink` sets it back to NULL. +- On a belongsToMany, `delete` removes this parent's pivot row and then deletes the related record through its model. `link` writes pivot rows and `unlink` deletes them, as before. + +Every child route is scoped to its parent. The parent is loaded through `pact.FormExtendQuery`, and the child must belong to it: on a hasMany by its `ForeignKey`, on a belongsToMany by a pivot row. A child of another parent, or any child of a parent the administrator cannot open, answers 404 `not_found`, never 403, so a request cannot tell a foreign record from a missing one. A delete is all or nothing: when one of the ids is not a child of the parent, nothing is deleted. + +A controller can hook into the child writes with the optional `pact.RelationBeforeCreate` and `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate` and `pact.RelationAfterUpdate`, and `pact.RelationBeforeDelete` and `pact.RelationAfterDelete`, each called with the relation name, the parent and the child. A hook error rolls the whole write back and answers the generic lifecycle error. + +### Pivot forms + +A belongsToMany relation can edit columns of its pivot rows with `pivot.form` (a pivot form on a hasMany stops the start-up): + +```yaml +editors: + label: acme.blog::lang.posts.editors + pivot: + form: $/acme/blog/models/posteditor/pivot_fields.yaml +``` + +WinterCMS names pivot form fields `pivot[role]`; both `pivot[role]` and the bare `role` are accepted and compile to the pivot column `role`. Each field must be a scalar or `datepicker` column of the pivot model, and never one of the pivot's foreign keys, `id`, `created_at`, `updated_at`, `deleted_at` or a `HookPivotColumns` entry: those stay server-owned. + +The SPA sends pivot values when it links one record: `{"ids": [7], "pivot": {"role": "reviewer"}}`. A `pivot` object with more than one id answers 422 on `ids`, and a key that is not a pivot form field answers 422 on that key. The values are filled into the pivot model through the pivot form's fields only, validated, and then `pact.RelationBeforeLink` stamps its hook columns as before. Later, GET and PUT `.../{id}/relations/{name}/pivot/{child}` read and save the same values on an existing link. Both need a pivot form and the `link` or `update` button, and a record not linked to the parent answers 404. ### Messages diff --git a/modules/cabana/README.md b/modules/cabana/README.md index ea8f085..a4fd11b 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -13,7 +13,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte - Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages. - Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly. - Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write. -- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback); WinterCMS `$///...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. +- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$///...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. - Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot. - Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file. - Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot. @@ -47,8 +47,11 @@ All paths are relative to `/api/v1`. A controller ID `vendor.plugin.cont | GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. | | GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. | | GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. | -| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. | +| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. A link body may carry `pivot` values for one id on a relation with a pivot form. On a hasMany, link sets and unlink clears the related record's foreign key. | | POST `.../{id}/relations/{name}/records` | Create a related record through the relation's manage form and attach it to the record (a hasMany foreign key is set by the server; a belongsToMany gets its pivot row). Needs `create` in the view panel's `toolbarButtons` (403 otherwise). | +| GET and PUT `.../{id}/relations/{name}/records/{child}` | Show a child (needs `update` or a view form) and save it through the manage form (needs `update`). | +| POST `.../{id}/relations/{name}/delete` | `{ids}`: delete children through their model (needs `delete`). A belongsToMany record loses this record's pivot row first. All or nothing. | +| GET and PUT `.../{id}/relations/{name}/pivot/{child}` | Read and save the pivot form values of one link (needs a pivot form and `link` or `update`). | | GET `.../{id}/files/{field}` | The files of a `type: fileupload` field: attached files minus the session's pending removals, plus its pending uploads, in `sort_order`. `{id}` 0 is the record being created and needs `X-Session-Key`. | | POST `.../{id}/files/{field}` | Upload one multipart `file_data` part; it is bound to the `X-Session-Key` session (required) and attached by the record's next save. Answers 201 with the pending `cabana.FileItem`. | | PUT `.../{id}/files/{field}/{file}` | Save a file's `title` and `description` at once; the field must declare `useCaption` (403 otherwise). | @@ -56,6 +59,8 @@ All paths are relative to `/api/v1`. A controller ID `vendor.plugin.cont | POST `.../{id}/files/{field}/reorder` | `{ids}` in the new order, exactly the field's visible files; they take the existing `sort_order` values at once. attachMany only (403 otherwise). | | GET `.../{id}/files/{field}/{file}/download`, GET `.../{id}/files/{field}/{file}/thumb` | Stream a protected file or its preview thumbnail; see the protected files note below. | +Every relation child and pivot route loads the record through `pact.FormExtendQuery` and finds the child with one query that carries the record: a hasMany child by its foreign key, a belongsToMany record by a pivot row. A child of another record is `not_found`, never `forbidden`. + Every file route resolves its file with one query scoped to the record (loaded through `pact.FormExtendQuery`) or to the administrator's own pending uploads: a file of another record is `not_found`, never `forbidden`. The save applies the session's file work in its transaction: a pending upload on an attachOne field replaces the file attached before, a deferred removal deletes the attached file, and the blobs of deleted files are removed after commit. After that the save checks `maxFiles` and a `required` fileupload field (at least one file) and answers 422 on the field when either fails; the pending work stays for the next attempt. Protected files (a relation with `Public` false) never get a public URL. The download and thumb routes serve only `is_public` false files, with `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and `Content-Security-Policy: default-src 'none'; sandbox`; JPEG, PNG, GIF and WebP are served inline with their type, every other type as an `application/octet-stream` attachment. The thumb route answers 404 for a file that is not one of those images. @@ -165,7 +170,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. | | `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. | | `cabana.RelationBelongsToMany` / `cabana.RelationHasMany` | The two `RelationContract.Kind` values; an empty `Kind` is a belongsToMany. | -| `cabana.AdminRelationChildCreate` | Swag annotation of the relation child create route. | +| `cabana.AdminRelationChildCreate` / `cabana.AdminRelationChildShow` / `cabana.AdminRelationChildUpdate` / `cabana.AdminRelationChildDelete` / `cabana.AdminRelationPivotShow` / `cabana.AdminRelationPivotUpdate` | Swag annotations of the relation child and pivot routes. | +| `cabana.RelationMutationInput` | Body of the link and unlink routes: `IDs` and, for a link of one id, `Pivot` form values. | +| `cabana.AdminRelationLinkRequest` | Documented body of the link route: `ids` and the optional `pivot` object. | | `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. | | `cabana.Allows` | Checks a principal against required permission codes. | | `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. | diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index 75590c4..5cfc6fa 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -67,11 +67,19 @@ type ListEnvelope[T any] struct { type AdminRecord map[string]any // AdminIDsRequest is the body of the id-list writes: bulk delete, relation -// link and relation unlink. +// unlink and relation child delete. type AdminIDsRequest struct { IDs []uint64 `json:"ids"` } +// AdminRelationLinkRequest is the body of the relation link route: the +// related ids and, on a belongsToMany relation with a pivot form, the pivot +// form values of exactly one linked id. Unknown keys are refused. +type AdminRelationLinkRequest struct { + IDs []uint64 `json:"ids"` + Pivot map[string]any `json:"pivot,omitempty"` +} + // AdminLoginRequest is the admin login body. Either login or email // identifies the backend user. type AdminLoginRequest struct { @@ -582,7 +590,7 @@ func AdminRelationCandidates() {} // @Param controller path string true "Controller" // @Param id path integer true "Owner id" // @Param name path string true "Relation name" -// @Param body body AdminIDsRequest true "Related record ids" +// @Param body body AdminRelationLinkRequest true "Related record ids and optional pivot form values" // @Success 200 {object} Envelope[RelationMutationResult] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope @@ -635,6 +643,119 @@ func AdminRelationUnlink() {} // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records [post] func AdminRelationChildCreate() {} +// AdminRelationChildShow documents the relation child show route. +// +// @Summary Show a related record +// @Description One child of the owner, projected through the manage form when the relation declares the update button, else through the view form (view.form, or the top-level form). A record that is not a child of this owner (hasMany: its foreign key; belongsToMany: a pivot row) is 404. Without either form the route answers 403. +// @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 id path integer true "Owner id" +// @Param name path string true "Relation name" +// @Param child path integer true "Related record id" +// @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}/relations/{name}/records/{child} [get] +func AdminRelationChildShow() {} + +// AdminRelationChildUpdate documents the relation child update route. +// +// @Summary Update a related record +// @Description Saves one child of the owner through the manage form, with the related model's rules and hooks. The view panel must declare the update toolbar button, otherwise 403. A record that is not a child of this owner is 404. +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Owner id" +// @Param name path string true "Relation name" +// @Param child path integer true "Related record id" +// @Param body body AdminRecord true "Field values of the manage form keyed by field name" +// @Success 200 {object} RecordEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 413 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child} [put] +func AdminRelationChildUpdate() {} + +// AdminRelationChildDelete documents the relation child delete route. +// +// @Summary Delete related records +// @Description Deletes children of the owner through their model: a hasMany child is deleted (hooks and soft delete run); a belongsToMany record loses this owner's pivot row and is then deleted. Every id must be a child of this owner, otherwise the whole request is 404 and nothing is deleted. The view panel must declare the delete toolbar button, otherwise 403. +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Owner id" +// @Param name path string true "Relation name" +// @Param body body AdminIDsRequest true "Related record ids" +// @Success 200 {object} Envelope[BulkResult] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 413 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete [post] +func AdminRelationChildDelete() {} + +// AdminRelationPivotShow documents the pivot show route. +// +// @Summary Show the pivot values of a link +// @Description The pivot form (pivot.form) values of the pivot row linking the owner and one related record, keyed by field name; id is the related record's id. Needs a pivot form and the link or update toolbar button (403 otherwise); a record not linked to this owner is 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 id path integer true "Owner id" +// @Param name path string true "Relation name" +// @Param child path integer true "Related record id" +// @Success 200 {object} Envelope[AdminRecord] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child} [get] +func AdminRelationPivotShow() {} + +// AdminRelationPivotUpdate documents the pivot update route. +// +// @Summary Update the pivot values of a link +// @Description Saves pivot form values on the pivot row linking the owner and one related record. Only pivot form fields are accepted (422 per unknown key); the pivot foreign keys, timestamps and hook columns can never be set. Gated and scoped like the pivot show route. +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Owner id" +// @Param name path string true "Relation name" +// @Param child path integer true "Related record id" +// @Param body body AdminRecord true "Pivot form values keyed by field name" +// @Success 200 {object} Envelope[AdminRecord] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 413 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child} [put] +func AdminRelationPivotUpdate() {} + // FileMutationResult is the payload of a file removal: the number of files // removed (always 1 on success). type FileMutationResult struct { diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 922056f..06a30ff 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -275,10 +275,22 @@ func (s *service) mount(r pact.Router) { constrainRelation(g) g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink)) constrainRelation(g) - // Relation child routes (D-11, D-12): create through the relation's - // manage form. + // Relation child routes (D-11, D-12, D-15): create, show, update and + // delete children through the relation's forms, every child scoped + // to the parent record. g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", requireAjax(s.relationChildCreate)) constrainRelation(g) + g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", s.relationChildShow) + constrainChild(g) + g.Put("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", requireAjax(s.relationChildUpdate)) + constrainChild(g) + g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete", requireAjax(s.relationChildDelete)) + constrainRelation(g) + // Pivot form values of one belongsToMany link (D-14). + g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", s.relationPivotShow) + constrainChild(g) + g.Put("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", requireAjax(s.relationPivotUpdate)) + constrainChild(g) // File routes of `type: fileupload` fields (D-09). {id} 0 is the // record being created in the X-Session-Key session. g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload)) @@ -318,6 +330,11 @@ func constrainRelation(g pact.Router) { g.Where("name", "[A-Za-z_][A-Za-z0-9_]*") } +func constrainChild(g pact.Router) { + constrainRelation(g) + g.Where("child", "[0-9]+") +} + func constrainFile(g pact.Router) { constrainController(g) g.Where("field", "[A-Za-z_][A-Za-z0-9_]*") diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index 8193a64..d98007b 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -216,6 +216,12 @@ func TestPhase10OpenAPIConformance(t *testing.T) { {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", 200, "cabana.Envelope-cabana_RelationMutationResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/link", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true) }, into[cabana.Envelope[cabana.RelationMutationResult]](), nil}, + {"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", 200, "cabana.Envelope-cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/pivot/%d", e.gadgetID, e.memberID), nil, true) + }, into[cabana.Envelope[cabana.AdminRecord]](), nil}, + {"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", 200, "cabana.Envelope-cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/pivot/%d", e.gadgetID, e.memberID), map[string]any{"note": "note-" + e.stamp}, true) + }, into[cabana.Envelope[cabana.AdminRecord]](), nil}, {"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members", e.gadgetID), nil, true) }, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil}, @@ -223,8 +229,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) { return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true) }, into[cabana.Envelope[cabana.RelationMutationResult]](), nil}, {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { - return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", e.gadgetID), map[string]any{"label": "part-" + e.stamp}, true) + rec := e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", e.gadgetID), map[string]any{"label": "part-" + e.stamp}, true) + e.partID = dataID(t, rec.Body.Bytes()) + return rec }, into[cabana.RecordEnvelope](), nil}, + {"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), nil, true) + }, into[cabana.RecordEnvelope](), nil}, + {"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d", e.gadgetID, e.partID), map[string]any{"label": "part-" + e.stamp + "-renamed"}, true) + }, into[cabana.RecordEnvelope](), nil}, + {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/delete", e.gadgetID), map[string]any{"ids": []uint{e.partID}}, true) + }, into[cabana.Envelope[cabana.BulkResult]](), nil}, {"POST /{vendor}/{plugin}/{controller}/toolbar/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { return e.send(t, http.MethodPost, "/acme/conform/gadgets/toolbar/recount", map[string]any{}, true) }, into[cabana.Envelope[cabana.AdminActionResult]](), nil}, @@ -344,6 +361,7 @@ type conformEnv struct { groupID uint memberID uint gadgetID uint + partID uint } // sendWith sends a raw body with extra headers through the assembled router. @@ -583,9 +601,12 @@ type conformMember struct { func (conformMember) TableName() string { return "cabana_conform_members" } type conformGadgetMember struct { - ID uint `gorm:"column:id;primaryKey"` - GadgetID uint `gorm:"column:gadget_id"` - MemberID uint `gorm:"column:member_id"` + ID uint `gorm:"column:id;primaryKey"` + GadgetID uint `gorm:"column:gadget_id"` + MemberID uint `gorm:"column:member_id"` + Note string `gorm:"column:note"` + // Stamp is a hook column RelationBeforeLink writes. + Stamp string `gorm:"column:stamp"` } func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" } @@ -648,11 +669,27 @@ func (conformController) AdminRelationContracts() []cabana.RelationContract { return []cabana.RelationContract{{ Name: "members", NewRelated: func() any { return &conformMember{} }, NewPivot: func() any { return &conformGadgetMember{} }, ParentForeignKey: "gadget_id", RelatedForeignKey: "member_id", Columns: map[string]string{"email": "email"}, + HookPivotColumns: []string{"stamp"}, }, { Name: "parts", Kind: cabana.RelationHasMany, NewRelated: func() any { return &conformPart{} }, ForeignKey: "gadget_id", Columns: map[string]string{"label": "label"}, }} } + +// RelationBeforeLink stamps the server-owned pivot column of a members link. +func (conformController) RelationBeforeLink(_ context.Context, relation string, _, _ any, pivot map[string]any) error { + if relation == "members" { + pivot["stamp"] = "linked" + } + return nil +} + +// FormExtendQuery hides gadgets whose name starts with hidden-, so the +// smoke tests can check that a hidden parent scopes its children away. +func (conformController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { + return db.Where("name NOT LIKE ?", "hidden-%") +} + func (conformController) AdminFieldRelations() []cabana.FieldRelationContract { return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}} } @@ -769,6 +806,8 @@ update: email: label: Email showSearch: true + pivot: + form: models/member/pivot_fields.yaml parts: label: Parts view: @@ -776,7 +815,7 @@ parts: columns: label: label: Label - toolbarButtons: create + toolbarButtons: create|update|delete|link|unlink manage: form: $/acme/conform/models/part/fields.yaml list: @@ -852,6 +891,11 @@ parts: fileTypes: [pdf, png, svg, txt] useCaption: true context: update +`), + "models/member/pivot_fields.yaml": file(`fields: + pivot[note]: + label: Note + type: text `), "models/part/fields.yaml": file(`fields: label: diff --git a/modules/cabana/phase10_coverage_test.go b/modules/cabana/phase10_coverage_test.go index 011840a..09ec0ee 100644 --- a/modules/cabana/phase10_coverage_test.go +++ b/modules/cabana/phase10_coverage_test.go @@ -99,15 +99,18 @@ func TestPhase10Coverage(t *testing.T) { "POST /{vendor}/{plugin}/{controller}/widgets/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder", + "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records", "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", "PUT /settings/{code}", "PUT /{vendor}/{plugin}/{controller}/{id}", "PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", + "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}", + "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}", } if strings.Join(unsafe, "\n") != strings.Join(want, "\n") { - t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 16 besides login):\n%s", strings.Join(unsafe, "\n")) + t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 19 besides login):\n%s", strings.Join(unsafe, "\n")) } // The routes added in Phase 10 are safe reads: GET /lang and the shared // nested pattern serving field options, filter options and relation lists. diff --git a/modules/cabana/phase10_csrf_test.go b/modules/cabana/phase10_csrf_test.go index 603e485..abeeab3 100644 --- a/modules/cabana/phase10_csrf_test.go +++ b/modules/cabana/phase10_csrf_test.go @@ -68,9 +68,10 @@ func TestPhase10CSRF(t *testing.T) { } // refresh, logout, settings put, create, bulk-delete, widget action, // toolbar action, update, delete, link, unlink, file upload, file - // reorder, file caption, file remove, relation child create - if unsafe != 16 { - t.Fatalf("walked %d state-changing routes, want 16: %v", unsafe, router.order) + // reorder, file caption, file remove, relation child create, update + // and delete, pivot update + if unsafe != 19 { + t.Fatalf("walked %d state-changing routes, want 19: %v", unsafe, router.order) } loginHandler := router.handlers[login] diff --git a/modules/cabana/relation.go b/modules/cabana/relation.go index 5cb8f6d..86db282 100644 --- a/modules/cabana/relation.go +++ b/modules/cabana/relation.go @@ -164,6 +164,10 @@ type RelationResult struct { // RelationMutationInput is the only accepted relation write payload. type RelationMutationInput struct { IDs []any `json:"ids"` + // Pivot holds pivot form values for a belongsToMany link of exactly one + // id (D-14). Only the relation's pivot.form fields are accepted; the + // pivot foreign keys, timestamps and hook columns never are. + Pivot map[string]any `json:"pivot,omitempty"` } // RelationMutationResult reports inserted/deleted pivot rows. @@ -668,6 +672,10 @@ func relationBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, } notExists := "NOT EXISTS (SELECT 1 FROM " + quotedIdent(tx, pivotTable) + " p WHERE p." + quotedIdent(tx, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk) + ")" q = q.Where(notExists, ownerPK) + var err error + if q, err = excludePendingCreated(tx, q, target); err != nil { + return nil, nil, lifecycleFailure(cc, err) + } } else { join := "JOIN " + quotedIdent(tx, pivotTable) + " p ON p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + " = " + quotedIdent(tx, targetTable) + "." + quotedIdent(tx, pk) q = q.Joins(join).Where("p."+quotedIdent(tx, cr.Contract.ParentForeignKey)+" = ?", ownerPK) @@ -699,9 +707,27 @@ func hasManyBaseQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, q = q.Where(clause.Not(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(excluded)})) } } + q, err := excludePendingCreated(tx, q, target) + if err != nil { + return nil, nil, lifecycleFailure(cc, err) + } return q.Where(clause.Eq{Column: fk, Value: nil}), target, nil } +// excludePendingCreated drops from a candidate query every row that a +// pending form session created (a live bind whose envelope says created, +// D-22): another parent must not adopt a child before the session that +// created it saves or the purge removes it (Pitfall 8). +func excludePendingCreated(tx *gorm.DB, q *gorm.DB, target any) (*gorm.DB, error) { + morph, err := lagoon.MorphType(tx, target) + if err != nil { + return nil, err + } + column := quotedIdent(tx, tableName(target)) + "." + quotedIdent(tx, primaryColumn(target)) + pending := "NOT EXISTS (SELECT 1 FROM " + quotedIdent(tx, "deferred_bindings") + " b WHERE b.slave_type = ? AND b.is_bind AND b.slave_id = CAST(" + column + " AS TEXT) AND b.pivot_data LIKE ?)" + return q.Where(pending, morph, `{"created":true%`), nil +} + // 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) { @@ -830,7 +856,12 @@ func relationSelects(db *gorm.DB, cr *CompiledRelation, target any, cols []Relat return out } -// Link inserts only currently eligible targets and never restamps existing rows. +// Link links eligible related records to the parent and never restamps +// existing links. On a belongsToMany it writes pivot rows (with the pivot +// form's values when the body carries a pivot object for one id); on a +// hasMany it sets each child's ForeignKey to the parent's key through the +// child model. Records already linked to this parent are skipped; any other +// id that is not a candidate fails the whole link with 422 on ids. func (s RelationService) Link(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) { ids, err := normalizeIDs(in.IDs) if err != nil { @@ -840,48 +871,172 @@ func (s RelationService) Link(ctx context.Context, cc *CompiledController, relat if err != nil { return RelationMutationResult{}, err } - if cr.hasMany() { - return RelationMutationResult{}, recordNotFound{} + if err := checkPivotInput(cr, ids, in.Pivot); err != nil { + return RelationMutationResult{}, err } var result RelationMutationResult err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) - parent, err := newWritableModel(cc) + parent, err := s.loadParent(ctx, tx, cc, ownerID) if err != nil { return err } - if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil { - return err - } - ownerPK := pkUint(parent) - pending, err := pendingRelationIDs(tx, cr, ownerPK, ids) - if err != nil || len(pending) == 0 { - return err - } - q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, true) - if err != nil { - return err - } - q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)}) - holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem())) - if err := q.Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}}).Find(holder.Interface()).Error; err != nil { - return err - } - if holder.Elem().Len() != len(pending) { - return relationInvalid("ids", "contains an ineligible target") - } - for i := 0; i < holder.Elem().Len(); i++ { - related := holder.Elem().Index(i).Addr().Interface() - if err := insertPivot(ctx, tx, cc, cr, parent, related, nil); err != nil { - return err - } - result.Linked++ - } - return nil + result.Linked, err = s.linkRelated(ctx, tx, cc, cr, parent.model, ids, in.Pivot, "ids") + return err }) return result, err } +// linkRelated links ids to the saved parent inside tx (the shared link +// path of the link route and of the deferred commit). Eligibility is the +// candidate query: RelationExtendManageQuery, ExcludedRelatedIDs, not linked +// yet, and not a child created in a pending session. An ineligible id is a +// 422 on errField. +func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint, pivot map[string]any, errField string) (int, error) { + ownerPK := pkUint(parent) + var pending []uint + var err error + if cr.hasMany() { + pending, err = pendingChildIDs(ctx, tx, cr, ownerPK, ids) + } else { + pending, err = pendingRelationIDs(tx, cr, ownerPK, ids) + } + if err != nil || len(pending) == 0 { + return 0, err + } + q, target, err := relationBaseQuery(ctx, tx, cc, cr, parent, true) + if err != nil { + return 0, err + } + q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)}) + holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem())) + if err := q.Order(clause.OrderByColumn{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}}).Find(holder.Interface()).Error; err != nil { + return 0, err + } + if holder.Elem().Len() != len(pending) { + return 0, relationInvalid(errField, "contains an ineligible target") + } + linked := 0 + for i := 0; i < holder.Elem().Len(); i++ { + related := holder.Elem().Index(i).Addr().Interface() + if cr.hasMany() { + if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil { + return 0, lifecycleFailure(cc, err) + } + if err := tx.WithContext(ctx).Save(related).Error; err != nil { + return 0, lifecycleFailure(cc, err) + } + linked++ + continue + } + var row any + if pivot != nil { + row = cr.Contract.NewPivot() + if err := s.fillPivot(ctx, tx, cr, row, pivot); err != nil { + return 0, err + } + } + if err := insertPivot(ctx, tx, cc, cr, parent, related, row); err != nil { + return 0, err + } + linked++ + } + return linked, nil +} + +// pendingChildIDs drops the ids a hasMany parent already owns. +func pendingChildIDs(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerID uint, ids []uint) ([]uint, error) { + target := cr.Contract.NewRelated() + var owned []uint + err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(target). + Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ForeignKey}, Value: ownerID}). + Where(clause.IN{Column: clause.Column{Name: primaryColumn(target)}, Values: uintValues(ids)}). + Pluck(primaryColumn(target), &owned).Error + if err != nil { + return nil, err + } + pending := make([]uint, 0, len(ids)) + for _, id := range ids { + if !slices.Contains(owned, id) { + pending = append(pending, id) + } + } + return pending, nil +} + +// checkPivotInput validates a link body's pivot object before any query +// (D-14): it needs a pivot form, exactly one id, and only pivot form keys. +func checkPivotInput(cr *CompiledRelation, ids []uint, pivot map[string]any) error { + if pivot == nil { + return nil + } + if cr.pivot == nil { + return relationInvalid("pivot", "This relation has no pivot form.") + } + if len(ids) != 1 { + return relationInvalid("ids", "A link with pivot values takes exactly one id.") + } + return checkPivotKeys(cr, pivot) +} + +// checkPivotKeys refuses every key that is not a writable pivot form field. +func checkPivotKeys(cr *CompiledRelation, values map[string]any) error { + details := map[string]any{} + for key := range values { + if !slices.ContainsFunc(cr.pivot.Writable, func(f WritableField) bool { return f.Name == key }) { + details[key] = []string{"The " + key + " field is not a pivot field."} + } + } + if len(details) > 0 { + return &ValidationError{Details: details} + } + return nil +} + +// fillPivot fills a pivot model from pivot form values (D-14): only the +// pivot form's writable fields are filled (the form is the whitelist, the +// pivot model needs no Fillable), then the form's required flags, the pivot +// model's rules for those fields and the datepicker bounds are checked. +// The pivot foreign keys, timestamps and hook columns are never in the form. +func (s RelationService) fillPivot(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, pivot any, values map[string]any) error { + if err := checkPivotKeys(cr, values); err != nil { + return err + } + form := cr.pivot + allowed := make([]string, 0, len(form.Writable)) + for _, field := range form.Writable { + allowed = append(allowed, field.FillKey) + } + projected := ProjectWritableFields(form, values) + if err := lagoon.Fill(pivot, allowed, projected, false); err != nil { + var typed *lagoon.FillTypeError + if errors.As(err, &typed) { + return &ValidationError{Details: fillTypeDetails(typed.Key)} + } + return &CapabilityError{ControllerID: controllerID(form)} + } + rules := map[string]string{} + for key, rule := range mergedRules(form, pivot, "") { + if slices.Contains(allowed, key) { + rules[key] = rule + } + } + msgs, err := lagoon.Validate(ctx, tx, pivot, rules, valuesForRules(pivot, rules), nil) + if err != nil { + return &CapabilityError{ControllerID: controllerID(form)} + } + for field, extra := range dateBoundDetails(ctx, s.tr, form, pivot, "") { + if msgs == nil { + msgs = map[string][]string{} + } + msgs[field] = append(msgs[field], extra...) + } + if len(msgs) > 0 { + return &ValidationError{Details: validationDetails(msgs)} + } + return nil +} + // insertPivot writes the pivot row linking related to the saved parent of a // belongsToMany relation. A filled pivot model (from the pivot form) may be // passed; RelationBeforeLink then stamps only its HookPivotColumns, and the @@ -913,50 +1068,92 @@ func insertPivot(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *C return tx.WithContext(ctx).Create(pivot).Error } -// Unlink deletes explicit pivot models so their lifecycle hooks run. +// Unlink detaches related records from the parent: on a belongsToMany it +// deletes this parent's pivot rows through the pivot model, on a hasMany it +// sets each owned child's ForeignKey to NULL through the child model. Ids +// that are not linked to this parent are ignored. func (s RelationService) Unlink(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in RelationMutationInput) (RelationMutationResult, error) { ids, err := normalizeIDs(in.IDs) if err != nil { return RelationMutationResult{}, err } + if in.Pivot != nil { + return RelationMutationResult{}, relationInvalid("pivot", "Unlink takes no pivot values.") + } cr, err := relationOf(cc, relation) if err != nil { return RelationMutationResult{}, err } - if cr.hasMany() { - return RelationMutationResult{}, recordNotFound{} - } var result RelationMutationResult err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) - parent, err := newWritableModel(cc) + parent, err := s.loadParent(ctx, tx, cc, ownerID) if err != nil { return err } - if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil { - return err - } - proto := cr.Contract.NewPivot() - t := reflect.TypeOf(proto) - holder := reflect.New(reflect.SliceOf(t.Elem())) - q := tx.WithContext(ctx).Model(proto).Clauses(clause.Locking{Strength: "UPDATE"}). - Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: pkUint(parent)}). - Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}). - Order(clause.OrderByColumn{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}}) - if err := q.Find(holder.Interface()).Error; err != nil { - return err - } - for i := 0; i < holder.Elem().Len(); i++ { - if err := tx.WithContext(ctx).Delete(holder.Elem().Index(i).Addr().Interface()).Error; err != nil { - return err - } - result.Removed++ - } - return nil + result.Removed, err = s.unlinkRelated(ctx, tx, cc, cr, parent.model, ids) + return err }) return result, err } +// unlinkRelated detaches ids from the saved parent inside tx (the shared +// unlink path of the unlink route and of the deferred commit). +func (s RelationService) unlinkRelated(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent any, ids []uint) (int, error) { + if cr.hasMany() { + target := cr.Contract.NewRelated() + holder := reflect.New(reflect.SliceOf(reflect.TypeOf(target).Elem())) + err := tx.WithContext(ctx).Model(target).Clauses(clause.Locking{Strength: "UPDATE"}). + Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ForeignKey}, Value: pkUint(parent)}). + Where(clause.IN{Column: clause.Column{Name: primaryColumn(target)}, Values: uintValues(ids)}). + Order(clause.OrderByColumn{Column: clause.Column{Name: primaryColumn(target)}}). + Find(holder.Interface()).Error + if err != nil { + return 0, err + } + for i := 0; i < holder.Elem().Len(); i++ { + child := holder.Elem().Index(i).Addr().Interface() + if err := setModelColumn(child, cr.Contract.ForeignKey, nil); err != nil { + return 0, lifecycleFailure(cc, err) + } + if err := tx.WithContext(ctx).Save(child).Error; err != nil { + return 0, lifecycleFailure(cc, err) + } + } + return holder.Elem().Len(), nil + } + rows, err := lockPivotRows(ctx, tx, cr, pkUint(parent), ids) + if err != nil { + return 0, err + } + for _, row := range rows { + if err := tx.WithContext(ctx).Delete(row).Error; err != nil { + return 0, err + } + } + return len(rows), nil +} + +// lockPivotRows loads this parent's pivot rows for the related ids FOR +// UPDATE, in related id order. +func lockPivotRows(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerPK uint, ids []uint) ([]any, error) { + proto := cr.Contract.NewPivot() + holder := reflect.New(reflect.SliceOf(reflect.TypeOf(proto).Elem())) + err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(proto).Clauses(clause.Locking{Strength: "UPDATE"}). + Where(clause.Eq{Column: clause.Column{Name: cr.Contract.ParentForeignKey}, Value: ownerPK}). + Where(clause.IN{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}, Values: uintValues(ids)}). + Order(clause.OrderByColumn{Column: clause.Column{Name: cr.Contract.RelatedForeignKey}}). + Find(holder.Interface()).Error + if err != nil { + return nil, err + } + out := make([]any, holder.Elem().Len()) + for i := range out { + out[i] = holder.Elem().Index(i).Addr().Interface() + } + return out, nil +} + func relationOf(cc *CompiledController, name string) (*CompiledRelation, error) { if cc == nil || !identifier(name) || cc.Relations == nil || cc.Relations[name] == nil { return nil, recordNotFound{} diff --git a/modules/cabana/relation_child.go b/modules/cabana/relation_child.go index 86ba041..9d84450 100644 --- a/modules/cabana/relation_child.go +++ b/modules/cabana/relation_child.go @@ -6,11 +6,14 @@ import ( "errors" "io" "net/http" + "strconv" + "strings" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // relationParent is the parent record of a relation route, loaded through @@ -138,23 +141,306 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController return result, nil } +// loadChild finds one child of the parent with a single query that carries +// the parent predicate (D-15): on a hasMany the child's ForeignKey must be +// the parent's key, on a belongsToMany a pivot row must link the child to +// the parent. A child of another parent is recordNotFound (404, never 403). +// lock adds FOR UPDATE. +func loadChild(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint, lock bool) (any, error) { + if parent == nil || childID == 0 { + return nil, recordNotFound{} + } + child := cr.Contract.NewRelated() + table := tableName(child) + pk := clause.Column{Table: table, Name: primaryColumn(child)} + q := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Model(child). + Where(clause.Eq{Column: pk, Value: castPK(child, childID)}) + if cr.hasMany() { + q = q.Where(clause.Eq{Column: clause.Column{Table: table, Name: cr.Contract.ForeignKey}, Value: parent.id}) + } else { + linked := "EXISTS (SELECT 1 FROM " + quotedIdent(tx, tableName(cr.Contract.NewPivot())) + " p WHERE p." + + quotedIdent(tx, cr.Contract.ParentForeignKey) + " = ? AND p." + quotedIdent(tx, cr.Contract.RelatedForeignKey) + + " = " + quotedIdent(tx, table) + "." + quotedIdent(tx, primaryColumn(child)) + ")" + q = q.Where(linked, parent.id) + } + if lock { + q = q.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := q.Take(child).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, recordNotFound{} + } + return nil, err + } + return child, nil +} + +// childForm is the form a child is shown with: the manage form when the +// relation declares update, else the view form. +func (cr *CompiledRelation) childForm() *CompiledController { + if cr.allows("update") && cr.child != nil { + return cr.child + } + return cr.view +} + +// ShowChild loads one child of the parent (D-15) and projects it through +// the manage form when the relation declares update, else the view form. +func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint) (RecordResult, error) { + if s.DB == nil { + return RecordResult{}, errors.New("cabana: database is not configured") + } + cr, err := relationOf(cc, relation) + if err != nil { + return RecordResult{}, err + } + form := cr.childForm() + if form == nil { + return RecordResult{}, recordNotFound{} + } + var result RecordResult + err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { + ctx = withTx(ctx, tx) + parent, err := s.loadParent(ctx, tx, cc, ownerID) + if err != nil { + return err + } + child, err := loadChild(ctx, tx, cr, parent, childID, false) + if err != nil { + return err + } + result, err = projectFullRecord(ctx, tx, form, child) + return err + }) + if err != nil { + return RecordResult{}, err + } + return result, nil +} + +// UpdateChild saves one child of the parent through the manage form (D-16): +// the child is loaded under the parent scope and locked, filled and +// validated like a controller update, and saved through its model between +// the optional pact.RelationBeforeUpdate and pact.RelationAfterUpdate hooks. +func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint, in RecordInput) (RecordResult, error) { + if s.DB == nil { + return RecordResult{}, errors.New("cabana: database is not configured") + } + cr, err := relationOf(cc, relation) + if err != nil { + return RecordResult{}, err + } + if cr.child == nil { + return RecordResult{}, &CapabilityError{ControllerID: controllerID(cc)} + } + var result RecordResult + err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { + ctx = withTx(ctx, tx) + parent, err := s.loadParent(ctx, tx, cc, ownerID) + if err != nil { + return err + } + child, err := loadChild(ctx, tx, cr, parent, childID, true) + if err != nil { + return err + } + if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "update"); err != nil { + return err + } + if hook, ok := cc.Controller.(pact.RelationBeforeUpdate); ok && hook != nil { + if err := hook.RelationBeforeUpdate(ctx, cr.Contract.Name, parent.model, child); err != nil { + return lifecycleFailure(cc, err) + } + } + if err := tx.WithContext(ctx).Save(child).Error; err != nil { + return lifecycleFailure(cc, err) + } + if hook, ok := cc.Controller.(pact.RelationAfterUpdate); ok && hook != nil { + if err := hook.RelationAfterUpdate(ctx, cr.Contract.Name, parent.model, child); err != nil { + return lifecycleFailure(cc, err) + } + } + result, err = projectFullRecord(ctx, tx, cr.child, child) + return err + }) + if err != nil { + return RecordResult{}, err + } + return result, nil +} + +// DeleteChildren deletes children of the parent (D-12). Every id must be a +// child of this parent, or the whole request is recordNotFound and nothing +// is deleted. Per child the optional pact.RelationBeforeDelete runs, then a +// hasMany child is deleted through its model (hooks and soft delete run), +// a belongsToMany record first loses this parent's pivot row and is then +// deleted through its model, then pact.RelationAfterDelete runs. +func (s RelationService) DeleteChildren(ctx context.Context, cc *CompiledController, relation string, ownerID uint, in BulkDeleteInput) (BulkResult, error) { + if s.DB == nil { + return BulkResult{}, errors.New("cabana: database is not configured") + } + ids, err := normalizeIDs(in.IDs) + if err != nil { + return BulkResult{}, err + } + cr, err := relationOf(cc, relation) + if err != nil { + return BulkResult{}, err + } + var result BulkResult + err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { + ctx = withTx(ctx, tx) + parent, err := s.loadParent(ctx, tx, cc, ownerID) + if err != nil { + return err + } + children := make([]any, 0, len(ids)) + for _, id := range ids { + child, err := loadChild(ctx, tx, cr, parent, id, true) + if err != nil { + return err + } + children = append(children, child) + } + for _, child := range children { + if err := s.deleteChild(ctx, tx, cc, cr, parent, child); err != nil { + return err + } + } + result.Deleted = len(children) + return nil + }) + if err != nil { + return BulkResult{}, err + } + return result, nil +} + +// deleteChild deletes one loaded child of the parent through its model. +func (s RelationService) deleteChild(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr *CompiledRelation, parent *relationParent, child any) error { + if hook, ok := cc.Controller.(pact.RelationBeforeDelete); ok && hook != nil { + if err := hook.RelationBeforeDelete(ctx, cr.Contract.Name, parent.model, child); err != nil { + return lifecycleFailure(cc, err) + } + } + if !cr.hasMany() && parent.id > 0 { + rows, err := lockPivotRows(ctx, tx, cr, parent.id, []uint{pkUint(child)}) + if err != nil { + return lifecycleFailure(cc, err) + } + for _, row := range rows { + if err := tx.WithContext(ctx).Delete(row).Error; err != nil { + return lifecycleFailure(cc, err) + } + } + } + if err := tx.WithContext(ctx).Delete(child).Error; err != nil { + return lifecycleFailure(cc, err) + } + if hook, ok := cc.Controller.(pact.RelationAfterDelete); ok && hook != nil { + if err := hook.RelationAfterDelete(ctx, cr.Contract.Name, parent.model, child); err != nil { + return lifecycleFailure(cc, err) + } + } + return nil +} + +// loadPivotRow loads this parent's pivot row for the related id, locked; a +// missing row is recordNotFound. +func loadPivotRow(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint) (any, error) { + if childID == 0 { + return nil, recordNotFound{} + } + rows, err := lockPivotRows(ctx, tx, cr, parent.id, []uint{childID}) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, recordNotFound{} + } + return rows[0], nil +} + +// ShowPivot returns the pivot form values of the link between the parent +// and one related record (D-14). +func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint) (map[string]any, error) { + if s.DB == nil { + return nil, errors.New("cabana: database is not configured") + } + cr, err := relationOf(cc, relation) + if err != nil { + return nil, err + } + if cr.pivot == nil { + return nil, recordNotFound{} + } + var data map[string]any + err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { + ctx = withTx(ctx, tx) + parent, err := s.loadParent(ctx, tx, cc, ownerID) + if err != nil { + return err + } + row, err := loadPivotRow(ctx, tx, cr, parent, childID) + if err != nil { + return err + } + data = pivotRecord(cr, row, childID) + return nil + }) + return data, err +} + +// UpdatePivot saves pivot form values on the link between the parent and +// one related record (D-14): only pivot form fields are accepted (an +// unknown key is a 422), and the pivot model is saved through GORM. +func (s RelationService) UpdatePivot(ctx context.Context, cc *CompiledController, relation string, ownerID, childID uint, values map[string]any) (map[string]any, error) { + if s.DB == nil { + return nil, errors.New("cabana: database is not configured") + } + cr, err := relationOf(cc, relation) + if err != nil { + return nil, err + } + if cr.pivot == nil { + return nil, recordNotFound{} + } + var data map[string]any + err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { + ctx = withTx(ctx, tx) + parent, err := s.loadParent(ctx, tx, cc, ownerID) + if err != nil { + return err + } + row, err := loadPivotRow(ctx, tx, cr, parent, childID) + if err != nil { + return err + } + if err := s.fillPivot(ctx, tx, cr, row, values); err != nil { + return err + } + if err := tx.WithContext(ctx).Save(row).Error; err != nil { + return lifecycleFailure(cc, err) + } + data = pivotRecord(cr, row, childID) + return nil + }) + return data, err +} + +// pivotRecord projects a pivot row through the pivot form, keyed by the +// related record's id. +func pivotRecord(cr *CompiledRelation, row any, childID uint) map[string]any { + data := projectRecord(cr.pivot, row) + data["id"] = childID + return data +} + // relationButton resolves the route's relation and refuses (403) a route // whose toolbar button the view panel does not declare. An unknown relation // is 404. It writes the response and returns nil on refusal. func (s *service) relationButton(w http.ResponseWriter, r *http.Request, cc *CompiledController, button string) *CompiledRelation { - cr, err := relationOf(cc, r.PathValue("name")) - if err != nil { - writeCRUDError(w, err) - return nil - } - if !cr.allows(button) { - if principal, _ := bouncer.User(r.Context()); principal != nil { - s.logAuth(r, "denied", principal.ID) - } - WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) - return nil - } - return cr + return s.relationAllowed(w, r, cc, func(cr *CompiledRelation) bool { return cr.allows(button) }) } // decodeCappedObject decodes a JSON object body capped at @@ -221,3 +507,218 @@ func (s *service) relationChildCreate(w http.ResponseWriter, r *http.Request) { WriteData(w, http.StatusCreated, rec.Data, rec.Meta) }) } + +// pathChildID parses {child}; anything but a positive integer is not found. +func pathChildID(r *http.Request) (uint, error) { + n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("child")), 10, 64) + if err != nil || n == 0 { + return 0, recordNotFound{} + } + return uint(n), nil +} + +// relationAllowed resolves the route's relation and refuses (403) unless +// allowed reports the relation declares what the route needs. An unknown +// relation is 404. It writes the response and returns nil on refusal. +func (s *service) relationAllowed(w http.ResponseWriter, r *http.Request, cc *CompiledController, allowed func(*CompiledRelation) bool) *CompiledRelation { + cr, err := relationOf(cc, r.PathValue("name")) + if err != nil { + writeCRUDError(w, err) + return nil + } + if !allowed(cr) { + if principal, _ := bouncer.User(r.Context()); principal != nil { + s.logAuth(r, "denied", principal.ID) + } + WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) + return nil + } + return cr +} + +// relationIDs parses {id} and {child}. +func relationIDs(r *http.Request) (uint, uint, error) { + id, err := pathID(r) + if err != nil { + return 0, 0, err + } + child, err := pathChildID(r) + if err != nil { + return 0, 0, err + } + return id, child, nil +} + +// relationChildShow serves GET .../{id}/relations/{name}/records/{child}: +// allowed when the relation declares update (with a manage form) or has a +// view form. +func (s *service) relationChildShow(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + cr := s.relationAllowed(w, r, cc, func(cr *CompiledRelation) bool { return cr.childForm() != nil }) + if cr == nil { + return + } + id, child, err := relationIDs(r) + if err != nil { + writeCRUDError(w, err) + return + } + svc, err := s.relations() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + rec, err := svc.ShowChild(r.Context(), cc, cr.Contract.Name, id, child) + if err != nil { + writeRelationError(w, err) + return + } + WriteData(w, http.StatusOK, rec.Data, rec.Meta) + }) +} + +// relationChildUpdate serves PUT .../{id}/relations/{name}/records/{child}. +func (s *service) relationChildUpdate(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + cr := s.relationButton(w, r, cc, "update") + if cr == nil { + return + } + id, child, err := relationIDs(r) + if err != nil { + writeCRUDError(w, err) + return + } + body, err := s.decodeCappedObject(w, r) + if err != nil { + writeRelationError(w, err) + return + } + svc, err := s.relations() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + rec, err := svc.UpdateChild(r.Context(), cc, cr.Contract.Name, id, child, RecordInput{Body: body}) + if err != nil { + writeRelationError(w, err) + return + } + WriteData(w, http.StatusOK, rec.Data, rec.Meta) + }) +} + +// relationChildDelete serves POST .../{id}/relations/{name}/delete with +// {ids}. +func (s *service) relationChildDelete(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + cr := s.relationButton(w, r, cc, "delete") + if cr == nil { + return + } + id, err := pathID(r) + if err != nil { + writeCRUDError(w, err) + return + } + var in BulkDeleteInput + if err := s.decodeStrictNumbers(w, r, &in); err != nil { + writeRelationError(w, err) + return + } + svc, err := s.relations() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + result, err := svc.DeleteChildren(r.Context(), cc, cr.Contract.Name, id, in) + if err != nil { + writeRelationError(w, err) + return + } + WriteData(w, http.StatusOK, result, nil) + }) +} + +// decodeStrictNumbers decodes a capped JSON body into dest with numbers +// kept as json.Number, unknown keys and trailing data refused. +func (s *service) decodeStrictNumbers(w http.ResponseWriter, r *http.Request, dest any) error { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.jsonCap())) + dec.UseNumber() + dec.DisallowUnknownFields() + if err := dec.Decode(dest); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + return err + } + return invalidBody() + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + return invalidBody() + } + return nil +} + +// pivotAllowed: the pivot routes need a pivot form and the link or update +// button. +func pivotAllowed(cr *CompiledRelation) bool { + return cr.pivot != nil && (cr.allows("link") || cr.allows("update")) +} + +// relationPivotShow serves GET .../{id}/relations/{name}/pivot/{child}. +func (s *service) relationPivotShow(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + cr := s.relationAllowed(w, r, cc, pivotAllowed) + if cr == nil { + return + } + id, child, err := relationIDs(r) + if err != nil { + writeCRUDError(w, err) + return + } + svc, err := s.relations() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + data, err := svc.ShowPivot(r.Context(), cc, cr.Contract.Name, id, child) + if err != nil { + writeRelationError(w, err) + return + } + WriteData(w, http.StatusOK, data, nil) + }) +} + +// relationPivotUpdate serves PUT .../{id}/relations/{name}/pivot/{child}. +func (s *service) relationPivotUpdate(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + cr := s.relationAllowed(w, r, cc, pivotAllowed) + if cr == nil { + return + } + id, child, err := relationIDs(r) + if err != nil { + writeCRUDError(w, err) + return + } + body, err := s.decodeCappedObject(w, r) + if err != nil { + writeRelationError(w, err) + return + } + svc, err := s.relations() + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + data, err := svc.UpdatePivot(r.Context(), cc, cr.Contract.Name, id, child, body) + if err != nil { + writeRelationError(w, err) + return + } + WriteData(w, http.StatusOK, data, nil) + }) +} diff --git a/modules/cabana/relation_child_smoke_test.go b/modules/cabana/relation_child_smoke_test.go index ae8b7c7..a84804a 100644 --- a/modules/cabana/relation_child_smoke_test.go +++ b/modules/cabana/relation_child_smoke_test.go @@ -4,7 +4,9 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" "slices" + "strings" "testing" ) @@ -78,3 +80,154 @@ func TestRelationChildSmokeCreate(t *testing.T) { t.Fatalf("create under a missing parent status=%d body=%s", missing.Code, missing.Body.String()) } } + +// relationPath is the admin API path of a gadget relation route. +func relationPath(gadget uint, relation, rest string) string { + return fmt.Sprintf("/acme/conform/gadgets/%d/relations/%s%s", gadget, relation, rest) +} + +// expectStatus fails the test unless rec has the status. +func expectStatus(t *testing.T, what string, rec *httptest.ResponseRecorder, status int) { + t.Helper() + if rec.Code != status { + t.Fatalf("%s status=%d want %d body=%s", what, rec.Code, status, rec.Body.String()) + } +} + +// createPart creates a part under a gadget through the relation manager. +func (e *conformEnv) createPart(t *testing.T, gadget uint, label string) uint { + t.Helper() + rec := e.send(t, http.MethodPost, relationPath(gadget, "parts", "/records"), map[string]any{"label": label}, true) + expectStatus(t, "create part", rec, http.StatusCreated) + return dataID(t, rec.Body.Bytes()) +} + +// TestRelationChildSmokeScope checks D-15 on every child route: a child of +// another parent, a member linked to another parent and a parent hidden by +// FormExtendQuery all answer 404, and a delete naming one foreign child +// deletes nothing. It also walks hasMany link, unlink and delete. +func TestRelationChildSmokeScope(t *testing.T) { + env := newConformEnv(t) + env.loginAs(t, env.login) + a := env.createGadget(t, "a-"+env.stamp, "") + b := env.createGadget(t, "b-"+env.stamp, "") + pa := env.createPart(t, a, "pa") + pb := env.createPart(t, b, "pb") + + expectStatus(t, "show foreign child", env.send(t, http.MethodGet, relationPath(a, "parts", fmt.Sprintf("/records/%d", pb)), nil, true), http.StatusNotFound) + expectStatus(t, "update foreign child", env.send(t, http.MethodPut, relationPath(a, "parts", fmt.Sprintf("/records/%d", pb)), map[string]any{"label": "stolen"}, true), http.StatusNotFound) + expectStatus(t, "delete foreign child", env.send(t, http.MethodPost, relationPath(a, "parts", "/delete"), map[string]any{"ids": []uint{pa, pb}}, true), http.StatusNotFound) + for _, id := range []uint{pa, pb} { + var n int64 + if err := env.db.Model(&conformPart{}).Where("id = ?", id).Count(&n).Error; err != nil || n != 1 { + t.Fatalf("part %d count=%d err=%v after a refused delete", id, n, err) + } + } + shown := env.send(t, http.MethodGet, relationPath(a, "parts", fmt.Sprintf("/records/%d", pa)), nil, true) + expectStatus(t, "show own child", shown, http.StatusOK) + updated := env.send(t, http.MethodPut, relationPath(a, "parts", fmt.Sprintf("/records/%d", pa)), map[string]any{"label": "pa-renamed", "gadget_id": b}, true) + expectStatus(t, "update own child", updated, http.StatusOK) + if owner := env.partOwner(t, pa); owner == nil || *owner != a { + t.Fatalf("update moved the part to %v", owner) + } + + // A member linked to B has no pivot row under A. + expectStatus(t, "link member to B", env.send(t, http.MethodPost, relationPath(b, "members", "/link"), map[string]any{"ids": []uint{env.memberID}}, true), http.StatusOK) + expectStatus(t, "pivot of B's member through A", env.send(t, http.MethodGet, relationPath(a, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, true), http.StatusNotFound) + expectStatus(t, "pivot update of B's member through A", env.send(t, http.MethodPut, relationPath(a, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"note": "x"}, true), http.StatusNotFound) + + // A parent FormExtendQuery hides is 404 on every child route. + hidden := conformGadget{Name: "hidden-" + env.stamp} + if err := env.db.Create(&hidden).Error; err != nil { + t.Fatal(err) + } + ph := conformPart{GadgetID: &hidden.ID, Label: "ph"} + if err := env.db.Create(&ph).Error; err != nil { + t.Fatal(err) + } + expectStatus(t, "hidden parent linked list", env.send(t, http.MethodGet, relationPath(hidden.ID, "parts", ""), nil, true), http.StatusNotFound) + expectStatus(t, "hidden parent child", env.send(t, http.MethodGet, relationPath(hidden.ID, "parts", fmt.Sprintf("/records/%d", ph.ID)), nil, true), http.StatusNotFound) + expectStatus(t, "hidden parent create", env.send(t, http.MethodPost, relationPath(hidden.ID, "parts", "/records"), map[string]any{"label": "x"}, true), http.StatusNotFound) + + // hasMany link adopts a free part, unlink frees it, delete removes it. + free := conformPart{Label: "free"} + if err := env.db.Create(&free).Error; err != nil { + t.Fatal(err) + } + candidates := env.send(t, http.MethodGet, relationPath(a, "parts", "/candidates"), nil, true) + expectStatus(t, "candidates", candidates, http.StatusOK) + if !strings.Contains(candidates.Body.String(), `"label":"free"`) || strings.Contains(candidates.Body.String(), `"label":"pb"`) { + t.Fatalf("hasMany candidates = %s", candidates.Body.String()) + } + expectStatus(t, "link owned part of B", env.send(t, http.MethodPost, relationPath(a, "parts", "/link"), map[string]any{"ids": []uint{pb}}, true), http.StatusUnprocessableEntity) + expectStatus(t, "link free part", env.send(t, http.MethodPost, relationPath(a, "parts", "/link"), map[string]any{"ids": []uint{free.ID}}, true), http.StatusOK) + if owner := env.partOwner(t, free.ID); owner == nil || *owner != a { + t.Fatalf("linked part owner = %v, want %d", owner, a) + } + expectStatus(t, "unlink part", env.send(t, http.MethodPost, relationPath(a, "parts", "/unlink"), map[string]any{"ids": []uint{free.ID, pb}}, true), http.StatusOK) + if owner := env.partOwner(t, free.ID); owner != nil { + t.Fatalf("unlinked part owner = %d", *owner) + } + if owner := env.partOwner(t, pb); owner == nil || *owner != b { + t.Fatalf("unlink through A freed B's part: %v", owner) + } + expectStatus(t, "delete own child", env.send(t, http.MethodPost, relationPath(a, "parts", "/delete"), map[string]any{"ids": []uint{pa}}, true), http.StatusOK) + var n int64 + if err := env.db.Model(&conformPart{}).Where("id = ?", pa).Count(&n).Error; err != nil || n != 0 { + t.Fatalf("deleted part count=%d err=%v", n, err) + } +} + +// TestRelationChildSmokePivot links a member with a pivot note (D-14): the +// note is stored, RelationBeforeLink still stamps its hook column, and pivot +// keys outside the pivot form are refused on link and on the pivot route. +func TestRelationChildSmokePivot(t *testing.T) { + env := newConformEnv(t) + env.loginAs(t, env.login) + g := env.createGadget(t, "g-"+env.stamp, "") + second := conformMember{Email: "second-" + env.stamp + "@example.test"} + if err := env.db.Create(&second).Error; err != nil { + t.Fatal(err) + } + + for name, body := range map[string]map[string]any{ + "foreign key": {"ids": []uint{env.memberID}, "pivot": map[string]any{"gadget_id": 99}}, + "hook column": {"ids": []uint{env.memberID}, "pivot": map[string]any{"stamp": "forged"}}, + "two ids": {"ids": []uint{env.memberID, second.ID}, "pivot": map[string]any{"note": "x"}}, + "hasMany link": nil, + } { + if body == nil { + rec := env.send(t, http.MethodPost, relationPath(g, "parts", "/link"), map[string]any{"ids": []uint{1}, "pivot": map[string]any{"note": "x"}}, true) + expectStatus(t, name, rec, http.StatusUnprocessableEntity) + continue + } + expectStatus(t, name, env.send(t, http.MethodPost, relationPath(g, "members", "/link"), body, true), http.StatusUnprocessableEntity) + } + var count int64 + if err := env.db.Model(&conformGadgetMember{}).Where("gadget_id = ?", g).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("refused links wrote %d pivot rows (%v)", count, err) + } + + expectStatus(t, "link with note", env.send(t, http.MethodPost, relationPath(g, "members", "/link"), map[string]any{"ids": []uint{env.memberID}, "pivot": map[string]any{"note": "hello"}}, true), http.StatusOK) + var row conformGadgetMember + if err := env.db.Where("gadget_id = ? AND member_id = ?", g, env.memberID).Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.Note != "hello" || row.Stamp != "linked" { + t.Fatalf("pivot row = %+v, want note hello and stamp linked", row) + } + + shown := env.send(t, http.MethodGet, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, true) + expectStatus(t, "pivot show", shown, http.StatusOK) + if !strings.Contains(shown.Body.String(), `"note":"hello"`) || strings.Contains(shown.Body.String(), "stamp") { + t.Fatalf("pivot show = %s", shown.Body.String()) + } + expectStatus(t, "pivot update with a foreign key", env.send(t, http.MethodPut, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"member_id": second.ID}, true), http.StatusUnprocessableEntity) + expectStatus(t, "pivot update", env.send(t, http.MethodPut, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"note": "changed"}, true), http.StatusOK) + if err := env.db.Where("gadget_id = ? AND member_id = ?", g, env.memberID).Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.Note != "changed" || row.Stamp != "linked" || row.MemberID != env.memberID { + t.Fatalf("pivot row after update = %+v", row) + } +} diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go index f6b5ea3..8526231 100644 --- a/modules/cabana/security_coverage_test.go +++ b/modules/cabana/security_coverage_test.go @@ -63,6 +63,11 @@ var phase09Routes = []adminRoute{ {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records"}, + {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}"}, + {key: "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}"}, + {key: "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete"}, + {key: "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}"}, + {key: "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}"}, {key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}", mounted: nestedGetRoute}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}"}, {key: "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder"}, @@ -295,6 +300,11 @@ func phase09ProtectedCalls() []phase09Call { {"relation-link", (*service).relationLink}, {"relation-unlink", (*service).relationUnlink}, {"relation-child-create", (*service).relationChildCreate}, + {"relation-child-show", (*service).relationChildShow}, + {"relation-child-update", (*service).relationChildUpdate}, + {"relation-child-delete", (*service).relationChildDelete}, + {"relation-pivot-show", (*service).relationPivotShow}, + {"relation-pivot-update", (*service).relationPivotUpdate}, {"file-list", (*service).fileList}, {"file-upload", (*service).fileUpload}, {"file-reorder", (*service).fileReorder},