feat(cabana): add markdown preview admin route

- POST {prefix}/api/v1/markdown/preview renders {markdown} through
  cabana.RenderMarkdown in the backend-guarded group behind requireAjax
- refused output is a 422 validation_failed on markdown with a fixed message
- swag annotation, regenerated admin.json and schema.d.ts
- route inventories, CSRF walk (26) and OpenAPI conformance learn the route
- README and docs/backend/forms.md document the route
This commit is contained in:
Jakub Zych
2026-10-06 20:53:07 +02:00
parent 0ff928d6cf
commit b492e79f2b
13 changed files with 517 additions and 6 deletions

View File

@@ -24,7 +24,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. `type: mltext` and `type: mlmarkdown` take a JSON object of locale code to string. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext` and `type: mlmarkdown` take a JSON object of locale code to string. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
- Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
@@ -48,6 +48,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. |
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
| GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. |
| GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record (needs a form and `create` in `toolbar.buttons`). |
| GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record (update and delete need a form). |
@@ -236,6 +237,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.ThumbOptions` | A fileupload field's `thumbOptions` (the preview thumbnail `mode`). |
| `cabana.FileMutationResult` | Answer of the file removal route: `removed`. |
| `cabana.AdminFileCaptionRequest` | Body of the file caption route: optional `title` and `description`. Unknown keys are refused. |
| `cabana.AdminMarkdownPreviewRequest` | Body of the markdown preview route: the `markdown` source. Unknown keys are refused. |
| `cabana.AdminMarkdownPreviewResult` | Answer of the markdown preview route: the `html` that `cabana.RenderMarkdown` produced. |
| `cabana.AdminMarkdownPreview` | Swag annotation of the markdown preview route. |
| `cabana.AdminFileList` / `cabana.AdminFileUpload` / `cabana.AdminFileUpdate` / `cabana.AdminFileRemove` / `cabana.AdminFileReorder` / `cabana.AdminFileDownload` / `cabana.AdminFileThumb` | Swag annotations of the file routes. |
| `cabana.AdminRelationChildFileList` / `cabana.AdminRelationChildFileUpload` / `cabana.AdminRelationChildFileUpdate` / `cabana.AdminRelationChildFileRemove` / `cabana.AdminRelationChildFileReorder` / `cabana.AdminRelationChildFileDownload` / `cabana.AdminRelationChildFileThumb` | Swag annotations of the relation child file routes. |
| `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). |

View File

@@ -245,6 +245,24 @@ func AdminSettingsGet() {}
// @Router /settings/{code} [put]
func AdminSettingsPut() {}
// AdminMarkdownPreview documents POST /markdown/preview.
//
// @Summary Render a markdown preview
// @Description Renders the markdown source through cabana.RenderMarkdown (goldmark without unsafe HTML) and answers the sanitized HTML. Output the renderer's gate refuses is a 422 on markdown. Any backend session may call it.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param body body AdminMarkdownPreviewRequest true "Markdown source"
// @Success 200 {object} Envelope[AdminMarkdownPreviewResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /markdown/preview [post]
func AdminMarkdownPreview() {}
// AdminListSchema documents the list schema route.
//
// @Summary Admin list schema

View File

@@ -3,8 +3,10 @@ package cabana
import (
"bytes"
"fmt"
"net/http"
"regexp"
"git.golem15.com/golem15/summercms/modules/bouncer"
"github.com/yuin/goldmark"
)
@@ -44,3 +46,44 @@ func rejectUnsafeMarkdownHTML(html string) error {
}
return nil
}
// msgMarkdownPreviewRefused is the fixed 422 detail of a preview whose
// rendered HTML the RenderMarkdown gate refuses. The renderer's error text is
// never written.
const msgMarkdownPreviewRefused = "The rendered HTML contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL and cannot be previewed."
// AdminMarkdownPreviewRequest is the body of POST /markdown/preview: the
// markdown source of a form field.
type AdminMarkdownPreviewRequest struct {
Markdown string `json:"markdown"`
}
// AdminMarkdownPreviewResult is the data of a POST /markdown/preview answer:
// the HTML RenderMarkdown produced for the source.
type AdminMarkdownPreviewResult struct {
HTML string `json:"html"`
}
// markdownPreview serves POST /markdown/preview: it renders the source
// through RenderMarkdown for the admin form preview. Any signed-in backend
// administrator may call it; it reads and writes no records. Output the
// RenderMarkdown gate refuses is a 422 on markdown with a fixed message.
func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
var body AdminMarkdownPreviewRequest
if err := s.decodeStrictBody(w, r, &body); err != nil {
writeCRUDError(w, err)
return
}
html, err := RenderMarkdown(body.Markdown)
if err != nil {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed",
map[string]any{"markdown": []string{msgMarkdownPreviewRefused}})
return
}
WriteData(w, http.StatusOK, AdminMarkdownPreviewResult{HTML: html}, nil)
}

View File

@@ -237,6 +237,8 @@ func (s *service) mount(r pact.Router) {
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
g.Get("/auth/me", s.me)
g.Get("/navigation", s.navigation)
// Form previews render through RenderMarkdown; nothing is stored.
g.Post("/markdown/preview", requireAjax(s.markdownPreview))
g.Get("/settings", s.settingsList)
g.Get("/settings/{code}/schema", s.settingsSchema)
constrainSetting(g)

View File

@@ -0,0 +1,33 @@
package cabana_test
import (
"net/http"
"strings"
"testing"
)
// TestMarkdownPreviewRoute drives POST /markdown/preview through the
// assembled router: without credentials the backend guard answers 401, and a
// signed-in administrator gets the sanitized rendering with raw script tags
// stripped by the renderer.
func TestMarkdownPreviewRoute(t *testing.T) {
env := newConformEnv(t)
anon := env.send(t, http.MethodPost, "/markdown/preview", map[string]string{"markdown": "# Hello"}, false)
if anon.Code != http.StatusUnauthorized {
t.Fatalf("anonymous status=%d body=%s", anon.Code, anon.Body.String())
}
env.loginAs(t, env.login)
rec := env.send(t, http.MethodPost, "/markdown/preview", map[string]string{"markdown": "# Hello\n\n<script>alert(1)</script>"}, true)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := strings.ToLower(rec.Body.String())
if strings.Contains(body, "<script") || strings.Contains(body, `<script`) {
t.Fatalf("script survived: %s", rec.Body.String())
}
if !strings.Contains(body, "hello") || !strings.Contains(body, "h1") {
t.Fatalf("heading missing: %s", rec.Body.String())
}
}

View File

@@ -0,0 +1,183 @@
package cabana
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// previewRequest builds a POST /markdown/preview request with a raw body and
// an optional principal in the context.
func previewRequest(body string, principal *bouncer.Principal) *http.Request {
req := httptest.NewRequest(http.MethodPost, adminAPI("/markdown/preview"), strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if principal != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
}
return req
}
func previewJSON(t *testing.T, markdown string) string {
t.Helper()
raw, err := json.Marshal(AdminMarkdownPreviewRequest{Markdown: markdown})
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func previewBackend() *bouncer.Principal {
return &bouncer.Principal{ID: 1, Backend: true}
}
// decodePreview decodes a 200 answer strictly into the documented envelope.
func decodePreview(t *testing.T, raw []byte) AdminMarkdownPreviewResult {
t.Helper()
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
var out Envelope[AdminMarkdownPreviewResult]
if err := dec.Decode(&out); err != nil {
t.Fatalf("decode %s: %v", raw, err)
}
return out.Data
}
// previewDetails returns the error code and details of an error answer.
func previewDetails(t *testing.T, raw []byte) (string, map[string][]string) {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
Details map[string][]string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("decode %s: %v", raw, err)
}
return body.Error.Code, body.Error.Details
}
func TestMarkdownPreviewRequiresBackendPrincipal(t *testing.T) {
for name, principal := range map[string]*bouncer.Principal{
"none": nil,
"frontend": {ID: 7, Backend: false},
} {
t.Run(name, func(t *testing.T) {
rec := httptest.NewRecorder()
(&service{}).markdownPreview(rec, previewRequest(previewJSON(t, "# Hello"), principal))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "unauthenticated")
})
}
}
func TestMarkdownPreviewRendersHTML(t *testing.T) {
rec := httptest.NewRecorder()
(&service{}).markdownPreview(rec, previewRequest(`{"markdown":"# Hello"}`, previewBackend()))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got := decodePreview(t, rec.Body.Bytes()); !strings.Contains(got.HTML, "<h1>Hello</h1>") {
t.Fatalf("html=%q", got.HTML)
}
empty := httptest.NewRecorder()
(&service{}).markdownPreview(empty, previewRequest(`{"markdown":""}`, previewBackend()))
if empty.Code != http.StatusOK {
t.Fatalf("empty status=%d body=%s", empty.Code, empty.Body.String())
}
if got := decodePreview(t, empty.Body.Bytes()); got.HTML != "" {
t.Fatalf("empty html=%q", got.HTML)
}
}
func TestMarkdownPreviewStripsUnsafeHTML(t *testing.T) {
needles := []string{"<script", "<iframe", "onerror", "javascript:", "vbscript:", "data:"}
cases := []struct {
name string
src string
must200 bool
mustMiss string
}{
{"script", "<script>alert(1)</script>", true, "<script"},
{"iframe", `<iframe src="https://evil.test"></iframe>`, false, "<iframe"},
{"onerror", `<img src=x onerror="alert(1)">`, false, "onerror"},
{"javascript", "[x](javascript:alert(1))", false, "javascript:"},
{"vbscript", "[x](vbscript:msgbox(1))", false, "vbscript:"},
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", false, "data:"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
(&service{}).markdownPreview(rec, previewRequest(previewJSON(t, tc.src), previewBackend()))
switch rec.Code {
case http.StatusOK:
html := strings.ToLower(decodePreview(t, rec.Body.Bytes()).HTML)
for _, needle := range needles {
if strings.Contains(html, needle) {
t.Fatalf("unsafe %q survived: %s", needle, html)
}
}
case http.StatusUnprocessableEntity:
if tc.must200 {
t.Fatalf("%s must be stripped and answered 200, got 422: %s", tc.name, rec.Body.String())
}
code, details := previewDetails(t, rec.Body.Bytes())
if code != "validation_failed" || len(details["markdown"]) == 0 {
t.Fatalf("code=%q details=%v", code, details)
}
if strings.Contains(strings.ToLower(rec.Body.String()), tc.mustMiss) {
t.Fatalf("refusal echoes %q: %s", tc.mustMiss, rec.Body.String())
}
default:
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
}
}
func TestMarkdownPreviewRefusesGatedOutput(t *testing.T) {
rec := httptest.NewRecorder()
(&service{}).markdownPreview(rec, previewRequest(previewJSON(t, "see data: here"), previewBackend()))
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
code, details := previewDetails(t, rec.Body.Bytes())
if code != "validation_failed" || len(details["markdown"]) == 0 || details["markdown"][0] == "" {
t.Fatalf("code=%q details=%v", code, details)
}
if strings.Contains(rec.Body.String(), "cabana:") || strings.Contains(rec.Body.String(), "dangerous URL scheme") {
t.Fatalf("refusal echoes the renderer error: %s", rec.Body.String())
}
}
func TestMarkdownPreviewRefusesInvalidBodies(t *testing.T) {
for name, body := range map[string]string{
"unknown key": `{"markdown":"x","extra":1}`,
"malformed": `{"markdown":`,
"trailing": `{"markdown":"x"} {}`,
} {
t.Run(name, func(t *testing.T) {
rec := httptest.NewRecorder()
(&service{}).markdownPreview(rec, previewRequest(body, previewBackend()))
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "validation_failed")
})
}
big := httptest.NewRecorder()
(&service{defaultBytes: 64}).markdownPreview(big, previewRequest(previewJSON(t, strings.Repeat("a", 200)), previewBackend()))
if big.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized status=%d body=%s", big.Code, big.Body.String())
}
assertErrorCode(t, big.Body.Bytes(), "payload_too_large")
}

View File

@@ -160,6 +160,9 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
{"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]](), nil},
{"POST /markdown/preview", 200, "cabana.Envelope-cabana_AdminMarkdownPreviewResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/markdown/preview", map[string]string{"markdown": "# Conform"}, true)
}, into[cabana.Envelope[cabana.AdminMarkdownPreviewResult]](), nil},
{"GET /{vendor}/{plugin}/{controller}/schema/list", 200, "cabana.Envelope-cabana_ListSchema", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodGet, "/acme/conform/gadgets/schema/list", nil, true)
var body cabana.Envelope[cabana.ListSchema]

View File

@@ -94,6 +94,7 @@ func TestPhase10Coverage(t *testing.T) {
"POST /auth/login",
"POST /auth/logout",
"POST /auth/refresh",
"POST /markdown/preview",
"POST /{vendor}/{plugin}/{controller}",
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
"POST /{vendor}/{plugin}/{controller}/bulk/{action}",
@@ -116,7 +117,7 @@ func TestPhase10Coverage(t *testing.T) {
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
}
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 25 besides login):\n%s", strings.Join(unsafe, "\n"))
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 26 besides login):\n%s", strings.Join(unsafe, "\n"))
}
// The routes added in Phase 10 are safe reads: GET /lang and the shared
// nested pattern serving field options, filter options and relation lists.

View File

@@ -66,14 +66,14 @@ func TestPhase10CSRF(t *testing.T) {
}
})
}
// refresh, logout, settings put, create, bulk-delete, bulk action,
// refresh, logout, markdown preview, settings put, create, bulk-delete, bulk action,
// widget action, toolbar action, update, delete, record action, link,
// unlink, file
// upload, file reorder, file caption, file remove, relation child
// create, update and delete, pivot update, child file upload, reorder,
// caption and remove
if unsafe != 25 {
t.Fatalf("walked %d state-changing routes, want 25: %v", unsafe, router.order)
if unsafe != 26 {
t.Fatalf("walked %d state-changing routes, want 26: %v", unsafe, router.order)
}
loginHandler := router.handlers[login]

View File

@@ -44,6 +44,7 @@ var phase09Routes = []adminRoute{
{key: "GET /settings/{code}/schema"},
{key: "GET /settings/{code}"},
{key: "PUT /settings/{code}"},
{key: "POST /markdown/preview"},
{key: "GET /{vendor}/{plugin}/{controller}/schema/list"},
{key: "GET /{vendor}/{plugin}/{controller}/schema/form"},
{key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},