diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index a4fec17..e131319 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -35,6 +35,17 @@ ], "type": "object" }, + "cabana.AdminLogoutData": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, "cabana.AdminProfile": { "properties": { "email": { @@ -106,6 +117,35 @@ ], "type": "object" }, + "cabana.BulkResult": { + "properties": { + "deleted": { + "type": "integer" + } + }, + "required": [ + "deleted" + ], + "type": "object" + }, + "cabana.Envelope-array_cabana_FilterOption": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/cabana.FilterOption" + }, + "type": "array" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-array_cabana_NavigationEntry": { "properties": { "data": { @@ -124,6 +164,24 @@ ], "type": "object" }, + "cabana.Envelope-array_cabana_SettingsEntry": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/cabana.SettingsEntry" + }, + "type": "array" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_AdminLoginData": { "properties": { "data": { @@ -139,6 +197,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_AdminLogoutData": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.AdminLogoutData" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_AdminProfile": { "properties": { "data": { @@ -154,6 +227,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_BulkResult": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.BulkResult" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_FormView": { "properties": { "data": { @@ -199,6 +287,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_RelationMutationResult": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.RelationMutationResult" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_RelationSchema": { "properties": { "data": { @@ -214,6 +317,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_SettingsResult": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.SettingsResult" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.ErrorBody": { "properties": { "code": { @@ -245,6 +363,21 @@ ], "type": "object" }, + "cabana.FilterOption": { + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ], + "type": "object" + }, "cabana.FormField": { "properties": { "attributes": { @@ -893,6 +1026,17 @@ ], "type": "object" }, + "cabana.RelationMutationResult": { + "properties": { + "linked": { + "type": "integer" + }, + "removed": { + "type": "integer" + } + }, + "type": "object" + }, "cabana.RelationOption": { "properties": { "label": { @@ -979,16 +1123,61 @@ ], "type": "object" }, - "cabana.SuccessEnvelope": { + "cabana.SettingsEntry": { "properties": { - "data": {}, - "meta": { - "$ref": "#/components/schemas/cabana.SuccessMeta" + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "description": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "keywords": { + "items": { + "type": "string" + }, + "type": "array" + }, + "label": { + "type": "string" + }, + "model": { + "type": "string" + }, + "order": { + "type": "integer" + } + }, + "required": [ + "category", + "code", + "description", + "icon", + "keywords", + "label", + "model", + "order" + ], + "type": "object" + }, + "cabana.SettingsResult": { + "properties": { + "data": { + "additionalProperties": {}, + "type": "object" + }, + "exists": { + "type": "boolean" } }, "required": [ "data", - "meta" + "exists" ], "type": "object" }, @@ -1115,7 +1304,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-cabana_AdminLogoutData" } } }, @@ -1130,6 +1319,26 @@ } }, "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" } }, "security": [ @@ -1165,6 +1374,26 @@ } }, "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" } }, "security": [ @@ -1281,6 +1510,16 @@ } }, "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" } }, "security": [ @@ -1301,7 +1540,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-array_cabana_SettingsEntry" } } }, @@ -1326,6 +1565,16 @@ } }, "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" } }, "security": [ @@ -1357,7 +1606,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-cabana_SettingsResult" } } }, @@ -1421,7 +1670,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-cabana_SettingsResult" } } }, @@ -1447,6 +1696,16 @@ }, "description": "Forbidden" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, "422": { "content": { "application/json": { @@ -1487,7 +1746,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-cabana_FormView" } } }, @@ -1637,6 +1896,16 @@ }, "description": "Forbidden" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, "422": { "content": { "application/json": { @@ -1720,6 +1989,16 @@ }, "description": "Forbidden" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, "422": { "content": { "application/json": { @@ -1778,7 +2057,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-cabana_BulkResult" } } }, @@ -1804,6 +2083,26 @@ }, "description": "Forbidden" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/json": { @@ -1954,6 +2253,100 @@ ] } }, + "/{vendor}/{plugin}/{controller}/filters/{scope}/options": { + "get": { + "description": "Choices of a declared scope filter of the controller's list, from the model's FilterOptions. {scope} is the filter name used as filter[\u003cscope\u003e]; labels are localized.", + "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": "Filter name", + "in": "path", + "name": "scope", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-array_cabana_FilterOption" + } + } + }, + "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" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Filter scope options", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/schema/form": { "get": { "parameters": [ @@ -2260,7 +2653,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-cabana_BulkResult" } } }, @@ -2295,6 +2688,16 @@ } }, "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" } }, "security": [ @@ -2554,7 +2957,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.ListEnvelope-array_cabana_AdminRecord" } } }, @@ -2579,6 +2982,26 @@ } }, "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": [ @@ -2646,7 +3069,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.ListEnvelope-array_cabana_AdminRecord" } } }, @@ -2671,6 +3094,26 @@ } }, "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": [ @@ -2738,7 +3181,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-cabana_RelationMutationResult" } } }, @@ -2764,6 +3207,16 @@ }, "description": "Forbidden" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, "422": { "content": { "application/json": { @@ -2840,7 +3293,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-cabana_RelationMutationResult" } } }, @@ -2866,6 +3319,16 @@ }, "description": "Forbidden" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, "422": { "content": { "application/json": { diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 697d04c..0ad56d4 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -82,7 +82,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-cabana_AdminLogoutData"]; }; }; /** @description Unauthorized */ @@ -94,6 +94,24 @@ export interface paths { "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"]; + }; + }; }; }; delete?: never; @@ -137,6 +155,24 @@ export interface paths { "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"]; + }; + }; }; }; put?: never; @@ -287,6 +323,15 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; }; }; put?: never; @@ -320,7 +365,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-array_cabana_SettingsEntry"]; }; }; /** @description Unauthorized */ @@ -341,6 +386,15 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; }; }; put?: never; @@ -377,7 +431,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-cabana_SettingsResult"]; }; }; /** @description Unauthorized */ @@ -428,7 +482,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-cabana_SettingsResult"]; }; }; /** @description Unauthorized */ @@ -449,6 +503,15 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -493,7 +556,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-cabana_FormView"]; }; }; /** @description Unauthorized */ @@ -595,6 +658,15 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -654,6 +726,15 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -703,7 +784,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-cabana_BulkResult"]; }; }; /** @description Unauthorized */ @@ -724,6 +805,24 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -832,6 +931,81 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/filters/{scope}/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Filter scope options + * @description Choices of a declared scope filter of the controller's list, from the model's FilterOptions. {scope} is the filter name used as filter[]; labels are localized. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Filter name */ + scope: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-array_cabana_FilterOption"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/schema/form": { parameters: { query?: never; @@ -1200,7 +1374,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-cabana_BulkResult"]; }; }; /** @description Unauthorized */ @@ -1230,6 +1404,15 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; }; }; options?: never; @@ -1271,7 +1454,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.ListEnvelope-array_cabana_AdminRecord"]; }; }; /** @description Unauthorized */ @@ -1292,6 +1475,24 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; }; }; put?: never; @@ -1336,7 +1537,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.ListEnvelope-array_cabana_AdminRecord"]; }; }; /** @description Unauthorized */ @@ -1357,6 +1558,24 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; }; }; put?: never; @@ -1403,7 +1622,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-cabana_RelationMutationResult"]; }; }; /** @description Unauthorized */ @@ -1424,6 +1643,15 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -1477,7 +1705,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-cabana_RelationMutationResult"]; }; }; /** @description Unauthorized */ @@ -1498,6 +1726,15 @@ export interface paths { "application/json": components["schemas"]["cabana.ErrorEnvelope"]; }; }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -1529,6 +1766,9 @@ export interface components { login?: string; password: string; }; + "cabana.AdminLogoutData": { + status: string; + }; "cabana.AdminProfile": { email: string; first_name: string; @@ -1550,18 +1790,37 @@ export interface components { label?: string; name: string; }; + "cabana.BulkResult": { + deleted: number; + }; + "cabana.Envelope-array_cabana_FilterOption": { + data: components["schemas"]["cabana.FilterOption"][]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-array_cabana_NavigationEntry": { data: components["schemas"]["cabana.NavigationEntry"][]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-array_cabana_SettingsEntry": { + data: components["schemas"]["cabana.SettingsEntry"][]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_AdminLoginData": { data: components["schemas"]["cabana.AdminLoginData"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_AdminLogoutData": { + data: components["schemas"]["cabana.AdminLogoutData"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_AdminProfile": { data: components["schemas"]["cabana.AdminProfile"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_BulkResult": { + data: components["schemas"]["cabana.BulkResult"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_FormView": { data: components["schemas"]["cabana.FormView"]; meta: components["schemas"]["cabana.SuccessMeta"]; @@ -1574,10 +1833,18 @@ export interface components { data: components["schemas"]["cabana.ListSchema"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_RelationMutationResult": { + data: components["schemas"]["cabana.RelationMutationResult"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_RelationSchema": { data: components["schemas"]["cabana.RelationSchema"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_SettingsResult": { + data: components["schemas"]["cabana.SettingsResult"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.ErrorBody": { code: string; details: { @@ -1588,6 +1855,10 @@ export interface components { "cabana.ErrorEnvelope": { error: components["schemas"]["cabana.ErrorBody"]; }; + "cabana.FilterOption": { + label: string; + value: string; + }; "cabana.FormField": { attributes?: { [key: string]: components["schemas"]["cabana.jsonScalar"]; @@ -1766,6 +2037,10 @@ export interface components { unlinkSelected: components["schemas"]["cabana.MessageForms"]; unlinked: components["schemas"]["cabana.MessageForms"]; }; + "cabana.RelationMutationResult": { + linked?: number; + removed?: number; + }; "cabana.RelationOption": { label: string; value: number; @@ -1791,9 +2066,21 @@ export interface components { name: string; url?: string; }; - "cabana.SuccessEnvelope": { - data: unknown; - meta: components["schemas"]["cabana.SuccessMeta"]; + "cabana.SettingsEntry": { + category: string; + code: string; + description: string; + icon: string; + keywords: string[]; + label: string; + model: string; + order: number; + }; + "cabana.SettingsResult": { + data: { + [key: string]: unknown; + }; + exists: boolean; }; "cabana.SuccessMeta": { last_page?: number; diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index de63f05..89bcde4 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -6,11 +6,33 @@ type Schemas = components['schemas'] export type AdminLoginData = Schemas['cabana.AdminLoginData'] export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest'] +export type AdminLogoutData = Schemas['cabana.AdminLogoutData'] export type AdminProfile = Schemas['cabana.AdminProfile'] export type NavigationEntry = Schemas['cabana.NavigationEntry'] +export type SettingsEntry = Schemas['cabana.SettingsEntry'] +export type SettingsResult = Schemas['cabana.SettingsResult'] export type ListSchema = Schemas['cabana.ListSchema'] export type ListColumn = Schemas['cabana.ListColumn'] +export type ListFilter = Schemas['cabana.ListFilter'] export type ListMeta = Schemas['cabana.ListMeta'] +export type ListMessages = Schemas['cabana.ListMessages'] +export type FormView = Schemas['cabana.FormView'] +export type FormField = Schemas['cabana.FormField'] +export type FormOption = Schemas['cabana.FormOption'] +export type FormMessages = Schemas['cabana.FormMessages'] +export type FormRedirects = Schemas['cabana.FormRedirects'] +export type RelationSchema = Schemas['cabana.RelationSchema'] +export type RelationMessages = Schemas['cabana.RelationMessages'] +export type RelationMutationResult = Schemas['cabana.RelationMutationResult'] +export type RelationOption = Schemas['cabana.RelationOption'] +export type FilterOption = Schemas['cabana.FilterOption'] +export type RecordMeta = Schemas['cabana.RecordMeta'] +export type RecordEnvelope = Schemas['cabana.RecordEnvelope'] +export type BulkResult = Schemas['cabana.BulkResult'] +/** One message as CLDR plural forms; "other" is always present (D-13). */ +export type MessageForms = Schemas['cabana.MessageForms'] +/** The public backend::lang bundle: full key to CLDR forms (D-20). */ +export type LangBundle = Schemas['cabana.LangBundle'] export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope'] /** One record: a string-keyed map read through its list or form schema. */ -export type AdminRecord = Schemas['cabana.ListEnvelope-array_cabana_AdminRecord']['data'][number] +export type AdminRecord = Schemas['cabana.AdminRecord'] diff --git a/admin/src/styles/main.css b/admin/src/styles/main.css index 312a4d5..98fe1a1 100644 --- a/admin/src/styles/main.css +++ b/admin/src/styles/main.css @@ -1,5 +1,11 @@ @import "tailwindcss"; +/* The generated API document and types carry prose, not classes: keep them + out of the class scan so an admin API change does not churn the built CSS + in boardwalk/dist (D-04 drift check). */ +@source not "../api/schema.d.ts"; +@source not "../../openapi"; + /* Self-hosted fonts (D-07). Whole-weight files carry latin and latin-ext with unicode-range, so Polish diacritics and basic Latin both resolve. */ @import "@fontsource/dm-sans/400.css"; diff --git a/cabana/admin_openapi.go b/cabana/admin_openapi.go index 104c3cd..75827ef 100644 --- a/cabana/admin_openapi.go +++ b/cabana/admin_openapi.go @@ -36,10 +36,9 @@ type SuccessMeta struct { LastPage int `json:"last_page,omitempty"` } -// SuccessEnvelope is the D-10 success envelope. -type SuccessEnvelope struct { - Data any `json:"data"` - Meta SuccessMeta `json:"meta"` +// AdminLogoutData is the POST /auth/logout payload. +type AdminLogoutData struct { + Status string `json:"status"` } // AdminLoginData is the admin login and refresh payload. Bearer transport @@ -139,8 +138,10 @@ func AdminLang() {} // @Tags admin // @Produce json // @Security BackendBearer -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[AdminLogoutData] // @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope // @Router /auth/logout [post] func AdminLogout() {} @@ -152,6 +153,8 @@ func AdminLogout() {} // @Security BackendBearer // @Success 200 {object} Envelope[AdminProfile] // @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope // @Router /auth/me [get] func AdminMe() {} @@ -164,6 +167,7 @@ func AdminMe() {} // @Success 200 {object} Envelope[[]NavigationEntry] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope // @Router /navigation [get] func AdminNavigation() {} @@ -173,9 +177,10 @@ func AdminNavigation() {} // @Tags admin // @Produce json // @Security BackendBearer -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[[]SettingsEntry] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope // @Router /settings [get] func AdminSettingsList() {} @@ -186,7 +191,7 @@ func AdminSettingsList() {} // @Produce json // @Security BackendBearer // @Param code path string true "Settings code" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[FormView] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope @@ -200,7 +205,7 @@ func AdminSettingsSchema() {} // @Produce json // @Security BackendBearer // @Param code path string true "Settings code" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[SettingsResult] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope @@ -215,10 +220,11 @@ func AdminSettingsGet() {} // @Produce json // @Security BackendBearer // @Param code path string true "Settings code" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[SettingsResult] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope // @Router /settings/{code} [put] func AdminSettingsPut() {} @@ -293,6 +299,24 @@ func AdminRelationSchema() {} // @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get] func AdminFieldOptions() {} +// AdminFilterOptions documents the model-backed filter options route (D-27). +// +// @Summary Filter scope options +// @Description Choices of a declared scope filter of the controller's list, from the model's FilterOptions. {scope} is the filter name used as filter[]; labels are localized. +// @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 scope path string true "Filter name" +// @Success 200 {object} Envelope[[]FilterOption] +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Router /{vendor}/{plugin}/{controller}/filters/{scope}/options [get] +func AdminFilterOptions() {} + // AdminList documents the record list route. // // @Summary List admin records @@ -311,6 +335,7 @@ func AdminFieldOptions() {} // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller} [get] func AdminList() {} @@ -329,6 +354,7 @@ func AdminList() {} // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller} [post] func AdminCreate() {} @@ -342,10 +368,12 @@ func AdminCreate() {} // @Param vendor path string true "Vendor" // @Param plugin path string true "Plugin" // @Param controller path string true "Controller" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[BulkResult] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 409 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller}/bulk-delete [post] func AdminBulkDelete() {} @@ -395,10 +423,11 @@ func AdminUpdate() {} // @Param plugin path string true "Plugin" // @Param controller path string true "Controller" // @Param id path integer true "Record id" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[BulkResult] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller}/{id} [delete] func AdminDelete() {} @@ -413,9 +442,11 @@ func AdminDelete() {} // @Param controller path string true "Controller" // @Param id path integer true "Owner id" // @Param name path string true "Relation name" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} ListEnvelope[[]AdminRecord] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name} [get] func AdminRelationLinked() {} @@ -430,9 +461,11 @@ func AdminRelationLinked() {} // @Param controller path string true "Controller" // @Param id path integer true "Owner id" // @Param name path string true "Relation name" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} ListEnvelope[[]AdminRecord] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get] func AdminRelationCandidates() {} @@ -448,10 +481,11 @@ func AdminRelationCandidates() {} // @Param controller path string true "Controller" // @Param id path integer true "Owner id" // @Param name path string true "Relation name" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[RelationMutationResult] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post] func AdminRelationLink() {} @@ -467,9 +501,10 @@ func AdminRelationLink() {} // @Param controller path string true "Controller" // @Param id path integer true "Owner id" // @Param name path string true "Relation name" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[RelationMutationResult] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post] func AdminRelationUnlink() {} diff --git a/cabana/auth.go b/cabana/auth.go index 3352d93..6b07418 100644 --- a/cabana/auth.go +++ b/cabana/auth.go @@ -265,7 +265,7 @@ func (s *service) logout(w http.ResponseWriter, r *http.Request) { } s.logAuth(r, "success", id) s.expireSessionCookie(w) - WriteData(w, http.StatusOK, map[string]any{"status": "logged_out"}, map[string]any{}) + WriteData(w, http.StatusOK, AdminLogoutData{Status: "logged_out"}, map[string]any{}) } // sessionToken returns the admin JWT the same way the backend guard reads it: diff --git a/cabana/export_test.go b/cabana/export_test.go new file mode 100644 index 0000000..9afbedd --- /dev/null +++ b/cabana/export_test.go @@ -0,0 +1,13 @@ +package cabana + +// AdminAPIRoutes exposes the admin API route inventory (method plus +// prefix-relative path) to the external conformance test. +func AdminAPIRoutes() []string { + out := make([]string, 0, len(phase09Routes)) + for _, route := range phase09Routes { + if !route.spa { + out = append(out, route.key) + } + } + return out +} diff --git a/cabana/filter_options_test.go b/cabana/filter_options_test.go new file mode 100644 index 0000000..7b851d1 --- /dev/null +++ b/cabana/filter_options_test.go @@ -0,0 +1,168 @@ +package cabana + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/phrasebook" + "git.golem15.com/golem15/summercms/towel" + "gorm.io/gorm" +) + +const filterOptionsScopes = `scopes: + grouped: + label: Group + modelClass: Group + nameFrom: name + scope: filterByGroup + activated: + label: Active + type: switch + column: active +` + +// panicOptionsRow proves the provider never runs for a denied principal. +type panicOptionsRow struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Active bool `gorm:"column:active"` +} + +func (panicOptionsRow) FilterScopes() []string { return []string{"filterByGroup"} } +func (panicOptionsRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB { + return db +} +func (panicOptionsRow) FilterOptions(string) []Option { + panic("FilterOptions ran for a denied request") +} + +// scopeOnlyRow has a filter scope but no choices source. +type scopeOnlyRow struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Active bool `gorm:"column:active"` +} + +func (scopeOnlyRow) FilterScopes() []string { return []string{"filterByGroup"} } +func (scopeOnlyRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB { + return db +} + +func filterOptionsService(t *testing.T, rec any, perms []string, tr *phrasebook.Translator) *service { + t.Helper() + ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: rec} + list, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, filterOptionsScopes)) + if err != nil { + t.Fatal(err) + } + app := backpack.New(nil) + if tr != nil { + if err := app.Publish(tr); err != nil { + t.Fatal(err) + } + } + permCtl := permissionedFilterController{filterController: ctl, perms: perms} + return &service{app: app, reg: &Registry{byID: map[string]*CompiledController{ + "acme.demo.widgets": {Controller: permCtl, List: list}, + }}} +} + +type permissionedFilterController struct { + filterController + perms []string +} + +func (c permissionedFilterController) RequiredPermissions() []string { return c.perms } + +func filterOptionsCall(svc *service, scope, locale string, principal *bouncer.Principal) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/widgets/filters/"+scope+"/options"), nil) + req.SetPathValue("vendor", "acme") + req.SetPathValue("plugin", "demo") + req.SetPathValue("controller", "widgets") + req.SetPathValue("id", "filters") + req.SetPathValue("segment", scope) + req.SetPathValue("name", "options") + ctx := req.Context() + if locale != "" { + ctx = towel.WithLocale(ctx, locale) + } + if principal != nil { + principal.Backend = true + ctx = bouncer.WithUser(ctx, principal) + } + rec := httptest.NewRecorder() + svc.nestedGet(rec, req.WithContext(ctx)) + return rec +} + +func filterOptionsLang() fstest.MapFS { + return fstest.MapFS{ + "lang/pl/lang.yaml": {Data: []byte("group_four: Grupa cztery\n")}, + "lang/en/lang.yaml": {Data: []byte("group_four: Group four\n")}, + } +} + +func TestPhase10FilterOptions(t *testing.T) { + app := backpack.New(nil) + demo := langPlugin{id: "demo", lang: filterOptionsLang()} + if err := phrasebook.Activate(app, []langPlugin{demo}); err != nil { + t.Fatal(err) + } + tr, _ := app.Lookup[*phrasebook.Translator]() + svc := filterOptionsService(t, &filterRow{}, []string{"acme.demo.access"}, tr) + granted := &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"acme.demo.access": true}} + + rec := filterOptionsCall(svc, "grouped", "pl", granted) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data []FilterOption `json:"data"` + Meta struct { + Locale string `json:"locale"` + } `json:"meta"` + } + dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())) + dec.DisallowUnknownFields() + if err := dec.Decode(&body); err != nil { + t.Fatal(err) + } + if body.Meta.Locale != "pl" || len(body.Data) != 2 || body.Data[0] != (FilterOption{Value: "4", Label: "Grupa cztery"}) || body.Data[1] != (FilterOption{Value: "9", Label: "Group nine"}) { + t.Fatalf("options=%+v locale=%q", body.Data, body.Meta.Locale) + } + if en := filterOptionsCall(svc, "grouped", "en", granted); !strings.Contains(en.Body.String(), `"label":"Group four"`) { + t.Fatalf("en labels=%s", en.Body.String()) + } + + for _, scope := range []string{"activated", "missing", "filterByGroup", "Grouped"} { + rec := filterOptionsCall(svc, scope, "", granted) + if rec.Code != http.StatusNotFound { + t.Fatalf("%s status=%d body=%s", scope, rec.Code, rec.Body.String()) + } + assertErrorCode(t, rec.Body.Bytes(), "not_found") + } + + locked := filterOptionsService(t, &panicOptionsRow{}, []string{"acme.demo.access"}, tr) + denied := filterOptionsCall(locked, "grouped", "", &bouncer.Principal{ID: 2}) + if denied.Code != http.StatusForbidden { + t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String()) + } + assertErrorCode(t, denied.Body.Bytes(), "forbidden") + anonymous := filterOptionsCall(locked, "grouped", "", nil) + if anonymous.Code != http.StatusUnauthorized { + t.Fatalf("anonymous status=%d body=%s", anonymous.Code, anonymous.Body.String()) + } + + ctl := filterController{schemaController: schemaController{model: "Widget"}, rec: &scopeOnlyRow{}} + _, err := CompileList("acme.demo", ctl, filterFS(filterListConfig, filterColumns, filterOptionsScopes)) + if err == nil || !strings.Contains(err.Error(), "grouped") || !strings.Contains(err.Error(), "FilterOptions") || !strings.Contains(err.Error(), "D-27") { + t.Fatalf("scope filter without FilterOptions err=%v", err) + } +} diff --git a/cabana/filter_schema.go b/cabana/filter_schema.go index 7ef1aea..c1da8ed 100644 --- a/cabana/filter_schema.go +++ b/cabana/filter_schema.go @@ -3,12 +3,14 @@ package cabana import ( "fmt" "io/fs" + "net/http" "path" "reflect" "strings" "time" "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/towel" "github.com/goccy/go-yaml/ast" ) @@ -232,16 +234,66 @@ func validateFilter(ctl pact.AdminController, provider pact.FilterScope, filter if provider == nil { return fmt.Errorf("scope %s requires FilterScope", filter.Scope) } + registered := false for _, name := range provider.FilterScopes() { if name == filter.Scope { - return nil + registered = true + break } } - return fmt.Errorf("scope %s is not registered", filter.Scope) + if !registered { + return fmt.Errorf("scope %s is not registered", filter.Scope) + } + if _, ok := provider.(pact.FilterOptions); !ok { + return fmt.Errorf("scope filter %s needs FilterOptions on the model to serve its choices (D-27)", filter.Name) + } } return nil } +// FilterOption is one model-backed filter choice (D-27). Values stay the +// strings the model returns; they are sent back as filter[]. +type FilterOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// filterOptions serves GET /{vendor}/{plugin}/{controller}/filters/{scope}/options +// for a declared scope filter of the controller's list. {scope} is the filter +// name (the filter[] key); the model receives the filter's scope method. +func (s *service) filterOptions(w http.ResponseWriter, r *http.Request) { + s.protect(w, r, func(cc *CompiledController) { + name := r.PathValue("scope") + var filter *ListFilter + if cc.List != nil && identifier(name) { + for i := range cc.List.Filters { + if cc.List.Filters[i].Name == name && cc.List.Filters[i].Type == "scope" { + filter = &cc.List.Filters[i] + break + } + } + } + if filter == nil { + writeNotFound(w, r) + return + } + provider, ok := filterProvider(cc.Controller).(pact.FilterOptions) + if !ok || provider == nil { + writeNotFound(w, r) + return + } + tr := s.translator() + locale := schemaLocale(r.Context(), tr) + ctx := towel.WithLocale(r.Context(), locale) + provided := provider.FilterOptions(filter.Scope) + out := make([]FilterOption, 0, len(provided)) + for _, opt := range provided { + out = append(out, FilterOption{Value: opt.Value, Label: translateKey(ctx, tr, opt.Label)}) + } + WriteData(w, http.StatusOK, out, map[string]any{"locale": locale}) + }) +} + func filterProvider(ctl pact.AdminController) pact.FilterScope { src, ok := ctl.(pact.AdminRecordSource) if !ok || src == nil { diff --git a/cabana/http.go b/cabana/http.go index dad0b6d..1295747 100644 --- a/cabana/http.go +++ b/cabana/http.go @@ -262,6 +262,7 @@ func constrainNested(g pact.Router) { // // GET /{vendor}/{plugin}/{controller}/{id}/relations/{name} // GET /{vendor}/{plugin}/{controller}/fields/{field}/options +// GET /{vendor}/{plugin}/{controller}/filters/{scope}/options // // A numeric id never equals a literal segment, so the dispatch is unambiguous. // Anything else is the D-10 not_found envelope, as an unmatched API path. @@ -271,6 +272,9 @@ func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) { case id == "fields" && name == "options": r.SetPathValue("field", segment) s.fieldOptions(w, r) + case id == "filters" && name == "options": + r.SetPathValue("scope", segment) + s.filterOptions(w, r) case segment == "relations": s.relationLinked(w, r) default: diff --git a/cabana/list_schema_test.go b/cabana/list_schema_test.go index c013c9b..e1849de 100644 --- a/cabana/list_schema_test.go +++ b/cabana/list_schema_test.go @@ -540,6 +540,14 @@ type filterRow struct { func (filterRow) FilterScopes() []string { return []string{"filterByGroup"} } +// FilterOptions serves the model-backed scope's choices (D-27). +func (filterRow) FilterOptions(scope string) []pact.Option { + if scope != "filterByGroup" { + return nil + } + return []pact.Option{{Value: "4", Label: "demo::lang.group_four"}, {Value: "9", Label: "Group nine"}} +} + func (filterRow) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { if db == nil || name != "filterByGroup" { return db diff --git a/cabana/openapi_conformance_test.go b/cabana/openapi_conformance_test.go new file mode 100644 index 0000000..6544f34 --- /dev/null +++ b/cabana/openapi_conformance_test.go @@ -0,0 +1,494 @@ +package cabana_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "testing/fstest" + "time" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/cabana" + "git.golem15.com/golem15/summercms/compass" + "git.golem15.com/golem15/summercms/lagoon" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/party" + "git.golem15.com/golem15/summercms/phrasebook" + "git.golem15.com/golem15/summercms/surf" + "gorm.io/gorm" +) + +// conformCase is one inventoried admin route called for real through the +// assembled router. decode must accept the body with unknown fields +// disallowed, and ref is the success schema admin.json documents. +type conformCase struct { + key string + status int + ref string + call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder + decode func(dec *json.Decoder) error +} + +func into[T any]() func(*json.Decoder) error { + return func(dec *json.Decoder) error { + var out T + return dec.Decode(&out) + } +} + +// TestPhase10OpenAPIConformance calls every route of the admin inventory +// against a PostgreSQL-backed fixture registry (acme names only), decodes +// each real response into the Go type its annotation documents with unknown +// fields disallowed, and checks admin/openapi/admin.json names that type for +// the same path, method and status (D-15, D-16; Pitfall 8). +func TestPhase10OpenAPIConformance(t *testing.T) { + env := newConformEnv(t) + spec := conformSpec(t) + + cases := []conformCase{ + {"POST /auth/login", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false) + }, into[cabana.Envelope[cabana.AdminLoginData]]()}, + {"POST /auth/refresh", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + rec := e.send(t, http.MethodPost, "/auth/refresh", nil, true) + e.token = accessToken(t, rec.Body.Bytes()) + return rec + }, into[cabana.Envelope[cabana.AdminLoginData]]()}, + {"GET /auth/me", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/auth/me", nil, true) + }, into[cabana.Envelope[cabana.AdminProfile]]()}, + {"GET /lang", 200, "cabana.Envelope-cabana_LangBundle", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/lang", nil, false) + }, into[cabana.Envelope[cabana.LangBundle]]()}, + {"GET /navigation", 200, "cabana.Envelope-array_cabana_NavigationEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/navigation", nil, true) + }, into[cabana.Envelope[[]cabana.NavigationEntry]]()}, + {"GET /settings", 200, "cabana.Envelope-array_cabana_SettingsEntry", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/settings", nil, true) + }, into[cabana.Envelope[[]cabana.SettingsEntry]]()}, + {"GET /settings/{code}/schema", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true) + }, into[cabana.Envelope[cabana.FormView]]()}, + {"GET /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/settings/conform", nil, true) + }, into[cabana.Envelope[cabana.SettingsResult]]()}, + {"PUT /settings/{code}", 200, "cabana.Envelope-cabana_SettingsResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true}, true) + }, into[cabana.Envelope[cabana.SettingsResult]]()}, + {"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true) + }, into[cabana.Envelope[cabana.ListSchema]]()}, + {"GET /{vendor}/{plugin}/{controller}/schema/form", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/form", nil, true) + }, into[cabana.Envelope[cabana.FormView]]()}, + {"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", 200, "cabana.Envelope-cabana_RelationSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/relation/members", nil, true) + }, into[cabana.Envelope[cabana.RelationSchema]]()}, + {"GET /{vendor}/{plugin}/{controller}/fields/{field}/options", 200, "cabana.ListEnvelope-array_cabana_RelationOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/acme/conform/gadgets/fields/group/options?search="+e.stamp, nil, true) + }, into[cabana.ListEnvelope[[]cabana.RelationOption]]()}, + {"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true) + }, into[cabana.Envelope[[]cabana.FilterOption]]()}, + {"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID}, true) + e.gadgetID = dataID(t, rec.Body.Bytes()) + return rec + }, into[cabana.RecordEnvelope]()}, + {"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true) + }, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()}, + {"GET /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true) + }, into[cabana.RecordEnvelope]()}, + {"PUT /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil}, true) + }, into[cabana.RecordEnvelope]()}, + {"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", 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/candidates?search=%s", e.gadgetID, e.stamp), nil, true) + }, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()}, + {"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]]()}, + {"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]]()}, + {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", 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/unlink", e.gadgetID), map[string]any{"ids": []uint{e.memberID}}, true) + }, into[cabana.Envelope[cabana.RelationMutationResult]]()}, + {"POST /{vendor}/{plugin}/{controller}/bulk-delete", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true) + return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true) + }, into[cabana.Envelope[cabana.BulkResult]]()}, + {"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true) + }, into[cabana.Envelope[cabana.BulkResult]]()}, + {"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPost, "/auth/logout", nil, true) + }, into[cabana.Envelope[cabana.AdminLogoutData]]()}, + } + + inventory := map[string]bool{} + for _, key := range cabana.AdminAPIRoutes() { + inventory[key] = true + } + covered := map[string]bool{} + for _, tc := range cases { + if !inventory[tc.key] { + t.Fatalf("conformance case %s is not an inventoried admin route", tc.key) + } + if covered[tc.key] { + t.Fatalf("duplicate conformance case %s", tc.key) + } + covered[tc.key] = true + } + for key := range inventory { + if !covered[key] { + t.Fatalf("admin route %s has no conformance case", key) + } + } + + for _, tc := range cases { + ok := t.Run(tc.key, func(t *testing.T) { + rec := tc.call(t, env) + if rec.Code != tc.status { + t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String()) + } + dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())) + dec.DisallowUnknownFields() + if err := tc.decode(dec); err != nil { + t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String()) + } + method, path, _ := strings.Cut(tc.key, " ") + got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status)) + if got != tc.ref { + t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref) + } + }) + if !ok { + t.FailNow() + } + } +} + +type conformSpecDoc struct { + Paths map[string]map[string]struct { + Responses map[string]struct { + Content map[string]struct { + Schema struct { + Ref string `json:"$ref"` + } `json:"schema"` + } `json:"content"` + } `json:"responses"` + } `json:"paths"` +} + +func (d conformSpecDoc) ref(path, method, status string) string { + ref := d.Paths[path][method].Responses[status].Content["application/json"].Schema.Ref + return strings.TrimPrefix(ref, "#/components/schemas/") +} + +func conformSpec(t *testing.T) conformSpecDoc { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("caller") + } + raw, err := os.ReadFile(filepath.Join(filepath.Dir(file), "..", "admin", "openapi", "admin.json")) + if err != nil { + t.Fatal(err) + } + var doc conformSpecDoc + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + return doc +} + +type conformEnv struct { + h http.Handler + login string + token string + stamp string + groupID uint + memberID uint + gadgetID uint +} + +func (e *conformEnv) send(t *testing.T, method, rel string, body any, auth bool) *httptest.ResponseRecorder { + t.Helper() + var payload []byte + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + payload = raw + } + req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept-Language", "pl") + if auth { + req.Header.Set("Authorization", "Bearer "+e.token) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + if method == http.MethodPost && rel == "/auth/login" && rec.Code == http.StatusOK { + e.token = accessToken(t, rec.Body.Bytes()) + } + return rec +} + +func dataID(t *testing.T, raw []byte) uint { + t.Helper() + var body struct { + Data struct { + ID uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &body); err != nil || body.Data.ID == 0 { + t.Fatalf("no data.id in %s: %v", raw, err) + } + return body.Data.ID +} + +func newConformEnv(t *testing.T) *conformEnv { + t.Helper() + gdb := adminGorm(t) + models := []any{&conformGadget{}, &conformGroup{}, &conformMember{}, &conformGadgetMember{}, &conformSettings{}} + if err := gdb.Migrator().DropTable(models...); err != nil { + t.Fatal(err) + } + if err := gdb.AutoMigrate(models...); err != nil { + t.Fatal(err) + } + stamp := fmt.Sprintf("c%d", time.Now().UnixNano()) + group := conformGroup{Title: "Group " + stamp} + member := conformMember{Email: "member-" + stamp + "@example.test"} + if err := gdb.Create(&group).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Create(&member).Error; err != nil { + t.Fatal(err) + } + login := "conform-" + stamp + insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-conform\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) + if err != nil { + t.Fatal(err) + } + for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} { + if err := cfg.Set(key, value); err != nil { + t.Fatal(err) + } + } + app := backpack.New(cfg) + if err := lagoon.Publish(app, adminSQL, gdb); err != nil { + t.Fatal(err) + } + plugins := []party.Plugin{conformPlugin{}} + if err := phrasebook.Activate(app, plugins); err != nil { + t.Fatal(err) + } + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatal(err) + } + return &conformEnv{h: h, login: login, stamp: stamp, groupID: group.ID, memberID: member.ID} +} + +type conformGadget struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Active bool `gorm:"column:active"` + GroupID *uint `gorm:"column:group_id"` + Members []conformMember `gorm:"-"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (conformGadget) TableName() string { return "cabana_conform_gadgets" } +func (conformGadget) Fillable() []string { return []string{"name", "active"} } +func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} } +func (conformGadget) FilterScopes() []string { return []string{"filterByGroup"} } +func (conformGadget) FilterScope(_ string, db *gorm.DB, value any) *gorm.DB { + return db.Where("group_id = ?", value) +} +func (conformGadget) FilterOptions(string) []pact.Option { + return []pact.Option{{Value: "1", Label: "backend::lang.form.none"}, {Value: "2", Label: "Second"}} +} + +type conformGroup struct { + ID uint `gorm:"column:id;primaryKey"` + Title string `gorm:"column:title"` +} + +func (conformGroup) TableName() string { return "cabana_conform_groups" } + +type conformMember struct { + ID uint `gorm:"column:id;primaryKey"` + Email string `gorm:"column:email"` +} + +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"` +} + +func (conformGadgetMember) TableName() string { return "cabana_conform_gadget_members" } + +type conformSettings struct { + ID uint `gorm:"column:id;primaryKey"` + Enabled bool `gorm:"column:enabled"` +} + +func (conformSettings) TableName() string { return "cabana_conform_settings" } +func (conformSettings) Fillable() []string { return []string{"enabled"} } +func (conformSettings) Rules() map[string]string { return map[string]string{} } + +type conformPlugin struct{} + +func (conformPlugin) ID() string { return "acme.conform" } +func (conformPlugin) Requires() []string { return nil } +func (conformPlugin) Register(*backpack.App) error { return nil } +func (conformPlugin) Boot(*backpack.App) error { return nil } +func (conformPlugin) AdminControllers() []pact.AdminController { + return []pact.AdminController{conformController{}} +} +func (conformPlugin) Permissions() []pact.Permission { + return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}} +} +func (conformPlugin) Navigation() []pact.NavigationItem { + return []pact.NavigationItem{{Code: "conform", Label: "Conform", Icon: "box", Order: 10, Controller: "acme.conform.gadgets", + Permissions: []string{"acme.conform.access"}, + SideMenu: []pact.NavigationItem{{Code: "gadgets", Label: "Gadgets", Icon: "box", Controller: "acme.conform.gadgets", Permissions: []string{"acme.conform.access"}}}}} +} +func (conformPlugin) Settings() []pact.SettingsItem { + return []pact.SettingsItem{{Code: "conform", Label: "Conform", Description: "Conform settings", Category: "Acme", Icon: "settings", + Model: "ConformSettings", Order: 10, Permissions: []string{"acme.conform.access"}, + Form: "models/settings/fields.yaml", NewModel: func() any { return &conformSettings{} }}} +} +func (conformPlugin) AdminFS() fs.FS { return conformFS() } + +type conformController struct{} + +func (conformController) ID() string { return "acme.conform.gadgets" } +func (conformController) ModelName() string { return "Gadget" } +func (conformController) ConfigDir() string { return "controllers/gadgets" } +func (conformController) RequiredPermissions() []string { return []string{"acme.conform.access"} } +func (conformController) NewRecord() any { return &conformGadget{} } +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"}, + }} +} +func (conformController) AdminFieldRelations() []cabana.FieldRelationContract { + return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}} +} + +func conformFS() fs.FS { + file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} } + return fstest.MapFS{ + "controllers/gadgets/config_list.yaml": file(`list: ~/plugins/acme/conform/models/gadget/columns.yaml +modelClass: Gadget +title: Gadgets +recordUrl: acme/conform/gadgets/update/:id +recordsPerPage: 20 +showCheckboxes: true +filter: config_filter.yaml +toolbar: + buttons: [create, delete] + search: + prompt: backend::lang.list.search_prompt +`), + "controllers/gadgets/config_filter.yaml": file(`scopes: + grouped: + label: Group + modelClass: Group + nameFrom: title + scope: filterByGroup + active: + label: Active + type: switch + column: active +`), + "controllers/gadgets/config_form.yaml": file(`name: New gadget +form: ~/plugins/acme/conform/models/gadget/fields.yaml +modelClass: Gadget +defaultRedirect: acme/conform/gadgets +create: + redirect: acme/conform/gadgets/update/:id + redirectClose: acme/conform/gadgets +update: + redirect: acme/conform/gadgets + redirectClose: acme/conform/gadgets +`), + "controllers/gadgets/config_relation.yaml": file(`members: + label: Members + view: + list: + columns: + email: + label: Email + toolbarButtons: link|unlink + showSearch: true + manage: + list: + columns: + email: + label: Email + showSearch: true +`), + "models/gadget/columns.yaml": file(`columns: + name: + label: Name + searchable: true + active: + label: Active + type: switch +`), + "models/gadget/fields.yaml": file(`fields: + name: + label: Name + type: text + required: true + span: left + active: + label: Active + type: switch + default: false + group: + label: Group + type: relation + nameFrom: title + emptyOption: backend::lang.form.none + members: + type: relation-manager + relation: members + context: [update] +`), + "models/settings/fields.yaml": file(`fields: + enabled: + label: Enabled + type: switch + default: 0 +`), + } +} diff --git a/cabana/query_test.go b/cabana/query_test.go index b5484e7..2f4a4a5 100644 --- a/cabana/query_test.go +++ b/cabana/query_test.go @@ -236,6 +236,14 @@ func (queryRow) TableName() string { return "cabana_list_query_rows" } func (queryRow) FilterScopes() []string { return []string{"filterByGroup"} } +// FilterOptions serves the grouped scope's choices (D-27). +func (queryRow) FilterOptions(scope string) []pact.Option { + if scope != "filterByGroup" { + return nil + } + return []pact.Option{{Value: "4", Label: "demo::lang.group_four"}, {Value: "9", Label: "Group nine"}} +} + func (queryRow) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { if db == nil || name != "filterByGroup" { return db @@ -426,3 +434,4 @@ func sameIDs(got, want []int) bool { var _ pact.AdminRecordSource = queryController{} var _ pact.AdminPermissioned = queryController{} var _ pact.FilterScope = queryRow{} +var _ pact.FilterOptions = queryRow{} diff --git a/cabana/schema_types.go b/cabana/schema_types.go index fae8aeb..7c3b77c 100644 --- a/cabana/schema_types.go +++ b/cabana/schema_types.go @@ -3,6 +3,7 @@ package cabana import ( "context" "encoding/json" + "fmt" ) // ListSort is the compiled default sort. Direction is exactly asc or desc. @@ -191,12 +192,45 @@ func (s jsonScalar) MarshalJSON() ([]byte, error) { return s.raw, nil } +// UnmarshalJSON accepts exactly a JSON scalar (string, number, boolean or +// null), so a served schema decodes back into its documented type. +func (s *jsonScalar) UnmarshalJSON(raw []byte) error { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return err + } + switch value.(type) { + case nil: + s.raw = nil + case string, float64, bool: + s.raw = append(json.RawMessage(nil), raw...) + default: + return fmt.Errorf("cabana: %s is not a JSON scalar", raw) + } + return nil +} + // fieldContext preserves a single Winter context string or a source-ordered list. type fieldContext struct { single bool values []string } +// UnmarshalJSON accepts the two served shapes: a string or a string list. +func (c *fieldContext) UnmarshalJSON(raw []byte) error { + var single string + if err := json.Unmarshal(raw, &single); err == nil { + *c = fieldContext{single: true, values: []string{single}} + return nil + } + var values []string + if err := json.Unmarshal(raw, &values); err != nil { + return fmt.Errorf("cabana: context must be a string or a list of strings") + } + *c = fieldContext{values: values} + return nil +} + func (c fieldContext) MarshalJSON() ([]byte, error) { if c.single { text := "" diff --git a/cabana/security_coverage_test.go b/cabana/security_coverage_test.go index dc56ad9..5d8107a 100644 --- a/cabana/security_coverage_test.go +++ b/cabana/security_coverage_test.go @@ -46,6 +46,7 @@ var phase09Routes = []adminRoute{ {key: "GET /{vendor}/{plugin}/{controller}/schema/form"}, {key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"}, {key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options", mounted: nestedGetRoute}, + {key: "GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", mounted: nestedGetRoute}, {key: "GET /{vendor}/{plugin}/{controller}"}, {key: "POST /{vendor}/{plugin}/{controller}"}, {key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"}, @@ -271,6 +272,7 @@ func phase09ProtectedCalls() []phase09Call { {"relation-schema", (*service).relationSchema}, {"relation-linked", (*service).relationLinked}, {"field-options", (*service).fieldOptions}, + {"filter-options", (*service).filterOptions}, {"nested-get", (*service).nestedGet}, {"relation-candidates", (*service).relationCandidates}, {"relation-link", (*service).relationLink}, diff --git a/pact/capabilities.go b/pact/capabilities.go index 2962ed3..bc4a3a6 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -262,6 +262,13 @@ type FilterScope interface { FilterScope(name string, db *gorm.DB, value any) *gorm.DB } +// FilterOptions serves the choices of a model-backed config_filter scope +// (D-27). It is implemented by the same model as FilterScope and receives the +// filter's scope method name; labels may be phrase keys. +type FilterOptions interface { + FilterOptions(scope string) []Option +} + // HasLang is implemented by plugins that ship embedded translation YAML // under lang//.yaml. type HasLang interface {