feat(10-02): filter choices and a fully typed admin API proven on the wire
- pact.FilterOptions on the model serves a scope filter's choices; a scope
filter whose model lacks it fails activation (D-27)
- GET /{vendor}/{plugin}/{controller}/filters/{scope}/options answers a
declared scope filter behind the controller permission with localized
{value, label} choices, 404 otherwise
- Every admin route documents a typed success schema, and protected routes
document 401, 403 and 404 (422 on writes); SuccessEnvelope is gone and
logout writes a typed AdminLogoutData
- jsonScalar and fieldContext decode their served shapes
- TestPhase10OpenAPIConformance calls every inventoried route through the
assembled router on PostgreSQL and decodes each body into its documented
type with unknown fields disallowed, checking admin.json's schema ref
- The SPA aliases every new schema type; Tailwind no longer scans the
generated API files, so API changes do not churn boardwalk/dist
This commit is contained in:
@@ -35,6 +35,17 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.AdminLogoutData": {
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.AdminProfile": {
|
"cabana.AdminProfile": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"email": {
|
"email": {
|
||||||
@@ -106,6 +117,35 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"cabana.Envelope-array_cabana_NavigationEntry": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -124,6 +164,24 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"cabana.Envelope-cabana_AdminLoginData": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -139,6 +197,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"cabana.Envelope-cabana_AdminProfile": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -154,6 +227,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"cabana.Envelope-cabana_FormView": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -199,6 +287,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"cabana.Envelope-cabana_RelationSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -214,6 +317,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"cabana.ErrorBody": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {
|
"code": {
|
||||||
@@ -245,6 +363,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.FilterOption": {
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"label",
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.FormField": {
|
"cabana.FormField": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -893,6 +1026,17 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.RelationMutationResult": {
|
||||||
|
"properties": {
|
||||||
|
"linked": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"removed": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.RelationOption": {
|
"cabana.RelationOption": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"label": {
|
"label": {
|
||||||
@@ -979,16 +1123,61 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"cabana.SuccessEnvelope": {
|
"cabana.SettingsEntry": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {},
|
"category": {
|
||||||
"meta": {
|
"type": "string"
|
||||||
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
},
|
||||||
|
"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": [
|
"required": [
|
||||||
"data",
|
"data",
|
||||||
"meta"
|
"exists"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -1115,7 +1304,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminLogoutData"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1130,6 +1319,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Unauthorized"
|
"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": [
|
"security": [
|
||||||
@@ -1165,6 +1374,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Unauthorized"
|
"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": [
|
"security": [
|
||||||
@@ -1281,6 +1510,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Forbidden"
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1301,7 +1540,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-array_cabana_SettingsEntry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1326,6 +1565,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Forbidden"
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1357,7 +1606,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_SettingsResult"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1421,7 +1670,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_SettingsResult"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1447,6 +1696,16 @@
|
|||||||
},
|
},
|
||||||
"description": "Forbidden"
|
"description": "Forbidden"
|
||||||
},
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -1487,7 +1746,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_FormView"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1637,6 +1896,16 @@
|
|||||||
},
|
},
|
||||||
"description": "Forbidden"
|
"description": "Forbidden"
|
||||||
},
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -1720,6 +1989,16 @@
|
|||||||
},
|
},
|
||||||
"description": "Forbidden"
|
"description": "Forbidden"
|
||||||
},
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -1778,7 +2057,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_BulkResult"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1804,6 +2083,26 @@
|
|||||||
},
|
},
|
||||||
"description": "Forbidden"
|
"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": {
|
"422": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"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": {
|
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||||
"get": {
|
"get": {
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -2260,7 +2653,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_BulkResult"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2295,6 +2688,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Not Found"
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unprocessable Entity"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": [
|
"security": [
|
||||||
@@ -2554,7 +2957,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.ListEnvelope-array_cabana_AdminRecord"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2579,6 +2982,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Forbidden"
|
"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": [
|
"security": [
|
||||||
@@ -2646,7 +3069,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.ListEnvelope-array_cabana_AdminRecord"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2671,6 +3094,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Forbidden"
|
"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": [
|
"security": [
|
||||||
@@ -2738,7 +3181,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_RelationMutationResult"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2764,6 +3207,16 @@
|
|||||||
},
|
},
|
||||||
"description": "Forbidden"
|
"description": "Forbidden"
|
||||||
},
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -2840,7 +3293,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/cabana.SuccessEnvelope"
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_RelationMutationResult"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2866,6 +3319,16 @@
|
|||||||
},
|
},
|
||||||
"description": "Forbidden"
|
"description": "Forbidden"
|
||||||
},
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
"422": {
|
"422": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
|
|||||||
315
admin/src/api/schema.d.ts
vendored
315
admin/src/api/schema.d.ts
vendored
@@ -82,7 +82,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminLogoutData"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -94,6 +94,24 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"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;
|
delete?: never;
|
||||||
@@ -137,6 +155,24 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"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;
|
put?: never;
|
||||||
@@ -287,6 +323,15 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
put?: never;
|
put?: never;
|
||||||
@@ -320,7 +365,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-array_cabana_SettingsEntry"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -341,6 +386,15 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
put?: never;
|
put?: never;
|
||||||
@@ -377,7 +431,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-cabana_SettingsResult"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -428,7 +482,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-cabana_SettingsResult"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -449,6 +503,15 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
/** @description Unprocessable Entity */
|
/** @description Unprocessable Entity */
|
||||||
422: {
|
422: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -493,7 +556,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-cabana_FormView"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -595,6 +658,15 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
/** @description Unprocessable Entity */
|
/** @description Unprocessable Entity */
|
||||||
422: {
|
422: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -654,6 +726,15 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
/** @description Unprocessable Entity */
|
/** @description Unprocessable Entity */
|
||||||
422: {
|
422: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -703,7 +784,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-cabana_BulkResult"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -724,6 +805,24 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"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 */
|
/** @description Unprocessable Entity */
|
||||||
422: {
|
422: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -832,6 +931,81 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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[<scope>]; 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": {
|
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1200,7 +1374,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-cabana_BulkResult"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -1230,6 +1404,15 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Unprocessable Entity */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -1271,7 +1454,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.ListEnvelope-array_cabana_AdminRecord"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -1292,6 +1475,24 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"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;
|
put?: never;
|
||||||
@@ -1336,7 +1537,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.ListEnvelope-array_cabana_AdminRecord"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -1357,6 +1558,24 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"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;
|
put?: never;
|
||||||
@@ -1403,7 +1622,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-cabana_RelationMutationResult"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -1424,6 +1643,15 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
/** @description Unprocessable Entity */
|
/** @description Unprocessable Entity */
|
||||||
422: {
|
422: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1477,7 +1705,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["cabana.SuccessEnvelope"];
|
"application/json": components["schemas"]["cabana.Envelope-cabana_RelationMutationResult"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
@@ -1498,6 +1726,15 @@ export interface paths {
|
|||||||
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
/** @description Unprocessable Entity */
|
/** @description Unprocessable Entity */
|
||||||
422: {
|
422: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1529,6 +1766,9 @@ export interface components {
|
|||||||
login?: string;
|
login?: string;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
"cabana.AdminLogoutData": {
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
"cabana.AdminProfile": {
|
"cabana.AdminProfile": {
|
||||||
email: string;
|
email: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
@@ -1550,18 +1790,37 @@ export interface components {
|
|||||||
label?: string;
|
label?: string;
|
||||||
name: 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": {
|
"cabana.Envelope-array_cabana_NavigationEntry": {
|
||||||
data: components["schemas"]["cabana.NavigationEntry"][];
|
data: components["schemas"]["cabana.NavigationEntry"][];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-array_cabana_SettingsEntry": {
|
||||||
|
data: components["schemas"]["cabana.SettingsEntry"][];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.Envelope-cabana_AdminLoginData": {
|
"cabana.Envelope-cabana_AdminLoginData": {
|
||||||
data: components["schemas"]["cabana.AdminLoginData"];
|
data: components["schemas"]["cabana.AdminLoginData"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-cabana_AdminLogoutData": {
|
||||||
|
data: components["schemas"]["cabana.AdminLogoutData"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.Envelope-cabana_AdminProfile": {
|
"cabana.Envelope-cabana_AdminProfile": {
|
||||||
data: components["schemas"]["cabana.AdminProfile"];
|
data: components["schemas"]["cabana.AdminProfile"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-cabana_BulkResult": {
|
||||||
|
data: components["schemas"]["cabana.BulkResult"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.Envelope-cabana_FormView": {
|
"cabana.Envelope-cabana_FormView": {
|
||||||
data: components["schemas"]["cabana.FormView"];
|
data: components["schemas"]["cabana.FormView"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
@@ -1574,10 +1833,18 @@ export interface components {
|
|||||||
data: components["schemas"]["cabana.ListSchema"];
|
data: components["schemas"]["cabana.ListSchema"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-cabana_RelationMutationResult": {
|
||||||
|
data: components["schemas"]["cabana.RelationMutationResult"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.Envelope-cabana_RelationSchema": {
|
"cabana.Envelope-cabana_RelationSchema": {
|
||||||
data: components["schemas"]["cabana.RelationSchema"];
|
data: components["schemas"]["cabana.RelationSchema"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-cabana_SettingsResult": {
|
||||||
|
data: components["schemas"]["cabana.SettingsResult"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.ErrorBody": {
|
"cabana.ErrorBody": {
|
||||||
code: string;
|
code: string;
|
||||||
details: {
|
details: {
|
||||||
@@ -1588,6 +1855,10 @@ export interface components {
|
|||||||
"cabana.ErrorEnvelope": {
|
"cabana.ErrorEnvelope": {
|
||||||
error: components["schemas"]["cabana.ErrorBody"];
|
error: components["schemas"]["cabana.ErrorBody"];
|
||||||
};
|
};
|
||||||
|
"cabana.FilterOption": {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
"cabana.FormField": {
|
"cabana.FormField": {
|
||||||
attributes?: {
|
attributes?: {
|
||||||
[key: string]: components["schemas"]["cabana.jsonScalar"];
|
[key: string]: components["schemas"]["cabana.jsonScalar"];
|
||||||
@@ -1766,6 +2037,10 @@ export interface components {
|
|||||||
unlinkSelected: components["schemas"]["cabana.MessageForms"];
|
unlinkSelected: components["schemas"]["cabana.MessageForms"];
|
||||||
unlinked: components["schemas"]["cabana.MessageForms"];
|
unlinked: components["schemas"]["cabana.MessageForms"];
|
||||||
};
|
};
|
||||||
|
"cabana.RelationMutationResult": {
|
||||||
|
linked?: number;
|
||||||
|
removed?: number;
|
||||||
|
};
|
||||||
"cabana.RelationOption": {
|
"cabana.RelationOption": {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
@@ -1791,9 +2066,21 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
};
|
};
|
||||||
"cabana.SuccessEnvelope": {
|
"cabana.SettingsEntry": {
|
||||||
data: unknown;
|
category: string;
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
code: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
keywords: string[];
|
||||||
|
label: string;
|
||||||
|
model: string;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
"cabana.SettingsResult": {
|
||||||
|
data: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
exists: boolean;
|
||||||
};
|
};
|
||||||
"cabana.SuccessMeta": {
|
"cabana.SuccessMeta": {
|
||||||
last_page?: number;
|
last_page?: number;
|
||||||
|
|||||||
@@ -6,11 +6,33 @@ type Schemas = components['schemas']
|
|||||||
|
|
||||||
export type AdminLoginData = Schemas['cabana.AdminLoginData']
|
export type AdminLoginData = Schemas['cabana.AdminLoginData']
|
||||||
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
|
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
|
||||||
|
export type AdminLogoutData = Schemas['cabana.AdminLogoutData']
|
||||||
export type AdminProfile = Schemas['cabana.AdminProfile']
|
export type AdminProfile = Schemas['cabana.AdminProfile']
|
||||||
export type NavigationEntry = Schemas['cabana.NavigationEntry']
|
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 ListSchema = Schemas['cabana.ListSchema']
|
||||||
export type ListColumn = Schemas['cabana.ListColumn']
|
export type ListColumn = Schemas['cabana.ListColumn']
|
||||||
|
export type ListFilter = Schemas['cabana.ListFilter']
|
||||||
export type ListMeta = Schemas['cabana.ListMeta']
|
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']
|
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
|
||||||
/** One record: a string-keyed map read through its list or form schema. */
|
/** 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']
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
@import "tailwindcss";
|
@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
|
/* Self-hosted fonts (D-07). Whole-weight files carry latin and latin-ext with
|
||||||
unicode-range, so Polish diacritics and basic Latin both resolve. */
|
unicode-range, so Polish diacritics and basic Latin both resolve. */
|
||||||
@import "@fontsource/dm-sans/400.css";
|
@import "@fontsource/dm-sans/400.css";
|
||||||
|
|||||||
@@ -36,10 +36,9 @@ type SuccessMeta struct {
|
|||||||
LastPage int `json:"last_page,omitempty"`
|
LastPage int `json:"last_page,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SuccessEnvelope is the D-10 success envelope.
|
// AdminLogoutData is the POST /auth/logout payload.
|
||||||
type SuccessEnvelope struct {
|
type AdminLogoutData struct {
|
||||||
Data any `json:"data"`
|
Status string `json:"status"`
|
||||||
Meta SuccessMeta `json:"meta"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminLoginData is the admin login and refresh payload. Bearer transport
|
// AdminLoginData is the admin login and refresh payload. Bearer transport
|
||||||
@@ -139,8 +138,10 @@ func AdminLang() {}
|
|||||||
// @Tags admin
|
// @Tags admin
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BackendBearer
|
// @Security BackendBearer
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[AdminLogoutData]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Router /auth/logout [post]
|
// @Router /auth/logout [post]
|
||||||
func AdminLogout() {}
|
func AdminLogout() {}
|
||||||
|
|
||||||
@@ -152,6 +153,8 @@ func AdminLogout() {}
|
|||||||
// @Security BackendBearer
|
// @Security BackendBearer
|
||||||
// @Success 200 {object} Envelope[AdminProfile]
|
// @Success 200 {object} Envelope[AdminProfile]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Router /auth/me [get]
|
// @Router /auth/me [get]
|
||||||
func AdminMe() {}
|
func AdminMe() {}
|
||||||
|
|
||||||
@@ -164,6 +167,7 @@ func AdminMe() {}
|
|||||||
// @Success 200 {object} Envelope[[]NavigationEntry]
|
// @Success 200 {object} Envelope[[]NavigationEntry]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Router /navigation [get]
|
// @Router /navigation [get]
|
||||||
func AdminNavigation() {}
|
func AdminNavigation() {}
|
||||||
|
|
||||||
@@ -173,9 +177,10 @@ func AdminNavigation() {}
|
|||||||
// @Tags admin
|
// @Tags admin
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BackendBearer
|
// @Security BackendBearer
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[[]SettingsEntry]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Router /settings [get]
|
// @Router /settings [get]
|
||||||
func AdminSettingsList() {}
|
func AdminSettingsList() {}
|
||||||
|
|
||||||
@@ -186,7 +191,7 @@ func AdminSettingsList() {}
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BackendBearer
|
// @Security BackendBearer
|
||||||
// @Param code path string true "Settings code"
|
// @Param code path string true "Settings code"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[FormView]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 404 {object} ErrorEnvelope
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
@@ -200,7 +205,7 @@ func AdminSettingsSchema() {}
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BackendBearer
|
// @Security BackendBearer
|
||||||
// @Param code path string true "Settings code"
|
// @Param code path string true "Settings code"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[SettingsResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 404 {object} ErrorEnvelope
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
@@ -215,10 +220,11 @@ func AdminSettingsGet() {}
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BackendBearer
|
// @Security BackendBearer
|
||||||
// @Param code path string true "Settings code"
|
// @Param code path string true "Settings code"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[SettingsResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 422 {object} ErrorEnvelope
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Router /settings/{code} [put]
|
// @Router /settings/{code} [put]
|
||||||
func AdminSettingsPut() {}
|
func AdminSettingsPut() {}
|
||||||
|
|
||||||
@@ -293,6 +299,24 @@ func AdminRelationSchema() {}
|
|||||||
// @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get]
|
// @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get]
|
||||||
func AdminFieldOptions() {}
|
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[<scope>]; 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.
|
// AdminList documents the record list route.
|
||||||
//
|
//
|
||||||
// @Summary List admin records
|
// @Summary List admin records
|
||||||
@@ -311,6 +335,7 @@ func AdminFieldOptions() {}
|
|||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 422 {object} ErrorEnvelope
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Router /{vendor}/{plugin}/{controller} [get]
|
// @Router /{vendor}/{plugin}/{controller} [get]
|
||||||
func AdminList() {}
|
func AdminList() {}
|
||||||
|
|
||||||
@@ -329,6 +354,7 @@ func AdminList() {}
|
|||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 422 {object} ErrorEnvelope
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Router /{vendor}/{plugin}/{controller} [post]
|
// @Router /{vendor}/{plugin}/{controller} [post]
|
||||||
func AdminCreate() {}
|
func AdminCreate() {}
|
||||||
|
|
||||||
@@ -342,10 +368,12 @@ func AdminCreate() {}
|
|||||||
// @Param vendor path string true "Vendor"
|
// @Param vendor path string true "Vendor"
|
||||||
// @Param plugin path string true "Plugin"
|
// @Param plugin path string true "Plugin"
|
||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[BulkResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 422 {object} ErrorEnvelope
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 409 {object} ErrorEnvelope
|
||||||
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
|
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
|
||||||
func AdminBulkDelete() {}
|
func AdminBulkDelete() {}
|
||||||
|
|
||||||
@@ -395,10 +423,11 @@ func AdminUpdate() {}
|
|||||||
// @Param plugin path string true "Plugin"
|
// @Param plugin path string true "Plugin"
|
||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Param id path integer true "Record id"
|
// @Param id path integer true "Record id"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[BulkResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 404 {object} ErrorEnvelope
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
// @Router /{vendor}/{plugin}/{controller}/{id} [delete]
|
// @Router /{vendor}/{plugin}/{controller}/{id} [delete]
|
||||||
func AdminDelete() {}
|
func AdminDelete() {}
|
||||||
|
|
||||||
@@ -413,9 +442,11 @@ func AdminDelete() {}
|
|||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Param id path integer true "Owner id"
|
// @Param id path integer true "Owner id"
|
||||||
// @Param name path string true "Relation name"
|
// @Param name path string true "Relation name"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} ListEnvelope[[]AdminRecord]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
|
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
|
||||||
func AdminRelationLinked() {}
|
func AdminRelationLinked() {}
|
||||||
|
|
||||||
@@ -430,9 +461,11 @@ func AdminRelationLinked() {}
|
|||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Param id path integer true "Owner id"
|
// @Param id path integer true "Owner id"
|
||||||
// @Param name path string true "Relation name"
|
// @Param name path string true "Relation name"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} ListEnvelope[[]AdminRecord]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
|
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
|
||||||
func AdminRelationCandidates() {}
|
func AdminRelationCandidates() {}
|
||||||
|
|
||||||
@@ -448,10 +481,11 @@ func AdminRelationCandidates() {}
|
|||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Param id path integer true "Owner id"
|
// @Param id path integer true "Owner id"
|
||||||
// @Param name path string true "Relation name"
|
// @Param name path string true "Relation name"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 422 {object} ErrorEnvelope
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
|
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
|
||||||
func AdminRelationLink() {}
|
func AdminRelationLink() {}
|
||||||
|
|
||||||
@@ -467,9 +501,10 @@ func AdminRelationLink() {}
|
|||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Param id path integer true "Owner id"
|
// @Param id path integer true "Owner id"
|
||||||
// @Param name path string true "Relation name"
|
// @Param name path string true "Relation name"
|
||||||
// @Success 200 {object} SuccessEnvelope
|
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
// @Failure 422 {object} ErrorEnvelope
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
|
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
|
||||||
func AdminRelationUnlink() {}
|
func AdminRelationUnlink() {}
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ func (s *service) logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.logAuth(r, "success", id)
|
s.logAuth(r, "success", id)
|
||||||
s.expireSessionCookie(w)
|
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:
|
// sessionToken returns the admin JWT the same way the backend guard reads it:
|
||||||
|
|||||||
13
cabana/export_test.go
Normal file
13
cabana/export_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
168
cabana/filter_options_test.go
Normal file
168
cabana/filter_options_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,14 @@ package cabana
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/pact"
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/towel"
|
||||||
"github.com/goccy/go-yaml/ast"
|
"github.com/goccy/go-yaml/ast"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,16 +234,66 @@ func validateFilter(ctl pact.AdminController, provider pact.FilterScope, filter
|
|||||||
if provider == nil {
|
if provider == nil {
|
||||||
return fmt.Errorf("scope %s requires FilterScope", filter.Scope)
|
return fmt.Errorf("scope %s requires FilterScope", filter.Scope)
|
||||||
}
|
}
|
||||||
|
registered := false
|
||||||
for _, name := range provider.FilterScopes() {
|
for _, name := range provider.FilterScopes() {
|
||||||
if name == filter.Scope {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FilterOption is one model-backed filter choice (D-27). Values stay the
|
||||||
|
// strings the model returns; they are sent back as filter[<name>].
|
||||||
|
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[<name>] 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 {
|
func filterProvider(ctl pact.AdminController) pact.FilterScope {
|
||||||
src, ok := ctl.(pact.AdminRecordSource)
|
src, ok := ctl.(pact.AdminRecordSource)
|
||||||
if !ok || src == nil {
|
if !ok || src == nil {
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ func constrainNested(g pact.Router) {
|
|||||||
//
|
//
|
||||||
// GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}
|
// GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}
|
||||||
// GET /{vendor}/{plugin}/{controller}/fields/{field}/options
|
// 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.
|
// 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.
|
// 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":
|
case id == "fields" && name == "options":
|
||||||
r.SetPathValue("field", segment)
|
r.SetPathValue("field", segment)
|
||||||
s.fieldOptions(w, r)
|
s.fieldOptions(w, r)
|
||||||
|
case id == "filters" && name == "options":
|
||||||
|
r.SetPathValue("scope", segment)
|
||||||
|
s.filterOptions(w, r)
|
||||||
case segment == "relations":
|
case segment == "relations":
|
||||||
s.relationLinked(w, r)
|
s.relationLinked(w, r)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -540,6 +540,14 @@ type filterRow struct {
|
|||||||
|
|
||||||
func (filterRow) FilterScopes() []string { return []string{"filterByGroup"} }
|
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 {
|
func (filterRow) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
||||||
if db == nil || name != "filterByGroup" {
|
if db == nil || name != "filterByGroup" {
|
||||||
return db
|
return db
|
||||||
|
|||||||
494
cabana/openapi_conformance_test.go
Normal file
494
cabana/openapi_conformance_test.go
Normal file
@@ -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
|
||||||
|
`),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,6 +236,14 @@ func (queryRow) TableName() string { return "cabana_list_query_rows" }
|
|||||||
|
|
||||||
func (queryRow) FilterScopes() []string { return []string{"filterByGroup"} }
|
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 {
|
func (queryRow) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
||||||
if db == nil || name != "filterByGroup" {
|
if db == nil || name != "filterByGroup" {
|
||||||
return db
|
return db
|
||||||
@@ -426,3 +434,4 @@ func sameIDs(got, want []int) bool {
|
|||||||
var _ pact.AdminRecordSource = queryController{}
|
var _ pact.AdminRecordSource = queryController{}
|
||||||
var _ pact.AdminPermissioned = queryController{}
|
var _ pact.AdminPermissioned = queryController{}
|
||||||
var _ pact.FilterScope = queryRow{}
|
var _ pact.FilterScope = queryRow{}
|
||||||
|
var _ pact.FilterOptions = queryRow{}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cabana
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListSort is the compiled default sort. Direction is exactly asc or desc.
|
// 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
|
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.
|
// fieldContext preserves a single Winter context string or a source-ordered list.
|
||||||
type fieldContext struct {
|
type fieldContext struct {
|
||||||
single bool
|
single bool
|
||||||
values []string
|
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) {
|
func (c fieldContext) MarshalJSON() ([]byte, error) {
|
||||||
if c.single {
|
if c.single {
|
||||||
text := ""
|
text := ""
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ var phase09Routes = []adminRoute{
|
|||||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/form"},
|
{key: "GET /{vendor}/{plugin}/{controller}/schema/form"},
|
||||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},
|
{key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},
|
||||||
{key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options", mounted: nestedGetRoute},
|
{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: "GET /{vendor}/{plugin}/{controller}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}"},
|
{key: "POST /{vendor}/{plugin}/{controller}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
||||||
@@ -271,6 +272,7 @@ func phase09ProtectedCalls() []phase09Call {
|
|||||||
{"relation-schema", (*service).relationSchema},
|
{"relation-schema", (*service).relationSchema},
|
||||||
{"relation-linked", (*service).relationLinked},
|
{"relation-linked", (*service).relationLinked},
|
||||||
{"field-options", (*service).fieldOptions},
|
{"field-options", (*service).fieldOptions},
|
||||||
|
{"filter-options", (*service).filterOptions},
|
||||||
{"nested-get", (*service).nestedGet},
|
{"nested-get", (*service).nestedGet},
|
||||||
{"relation-candidates", (*service).relationCandidates},
|
{"relation-candidates", (*service).relationCandidates},
|
||||||
{"relation-link", (*service).relationLink},
|
{"relation-link", (*service).relationLink},
|
||||||
|
|||||||
@@ -262,6 +262,13 @@ type FilterScope interface {
|
|||||||
FilterScope(name string, db *gorm.DB, value any) *gorm.DB
|
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
|
// HasLang is implemented by plugins that ship embedded translation YAML
|
||||||
// under lang/<locale>/<group>.yaml.
|
// under lang/<locale>/<group>.yaml.
|
||||||
type HasLang interface {
|
type HasLang interface {
|
||||||
|
|||||||
Reference in New Issue
Block a user