- pact.FormFieldType gains RequestOptions, called with the application's database once per form schema request for each type the form uses - the form schema route merges the answer into a copy of each field's typeOptions; a fields.yaml key wins, the compiled schema never changes - an error logs one warning naming the type and serves the fields.yaml options only - TestFieldTypeRequestOptions covers every screen, the precedence, the call count, the error path, unchanged schemas, form extension fields and the authenticated handler; a runnable example backs the docs - the typeOptions description, the regenerated OpenAPI document and schema.d.ts, the field types page and the pact and cabana READMEs document it
293 lines
10 KiB
Go
293 lines
10 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// roNotesFields declares the notes type four times: without a context and
|
|
// once each for the create, update and preview screens. Every one sets rows,
|
|
// which the type's request options also name.
|
|
const roNotesFields = ftNotesField +
|
|
" notes_create:\n type: notes\n label: Create\n context: create\n rows: 4\n" +
|
|
" notes_update:\n type: notes\n label: Update\n context: update\n rows: 4\n" +
|
|
" notes_preview:\n type: notes\n label: Preview\n context: preview\n rows: 4\n"
|
|
|
|
// roNote is the model of the request options controller: one column per
|
|
// notes field of roNotesFields.
|
|
type roNote struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Title string `gorm:"column:title"`
|
|
Notes string `gorm:"column:notes"`
|
|
NotesCreate string `gorm:"column:notes_create"`
|
|
NotesUpdate string `gorm:"column:notes_update"`
|
|
NotesPreview string `gorm:"column:notes_preview"`
|
|
}
|
|
|
|
func (*roNote) Fillable() []string {
|
|
return []string{"title", "notes", "notes_create", "notes_update", "notes_preview"}
|
|
}
|
|
func (*roNote) Rules() map[string]string { return map[string]string{} }
|
|
|
|
// roController is ftController editing roNote.
|
|
type roController struct{ ftController }
|
|
|
|
func (roController) NewRecord() any { return &roNote{} }
|
|
|
|
// roPlugin is ftPlugin with roController.
|
|
type roPlugin struct{ ftPlugin }
|
|
|
|
func (roPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{roController{}}
|
|
}
|
|
|
|
// roDemo is the request options plugin with the notes type typ.
|
|
func roDemo(typ pact.FormFieldType) roPlugin {
|
|
return roPlugin{ftPlugin{id: "acme.demo", fsys: ftFS(roNotesFields), types: []pact.FormFieldType{typ}}}
|
|
}
|
|
|
|
// roCounter counts RequestOptions calls.
|
|
type roCounter struct{ calls int }
|
|
|
|
// roNotesType is the notes type whose request options add headingLevels and
|
|
// try to override rows; err makes them fail.
|
|
func roNotesType(counter *roCounter, err error) pact.FormFieldType {
|
|
typ := ftNotesType()
|
|
typ.Options = []string{"rows", "headingLevels"}
|
|
typ.RequestOptions = func(_ context.Context, _ *gorm.DB) (map[string]any, error) {
|
|
counter.calls++
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"headingLevels": []any{2, 3, 4}, "rows": 9}, nil
|
|
}
|
|
return typ
|
|
}
|
|
|
|
// roSchema serves the form schema of acme.demo.notes as principal and
|
|
// returns the status, the fields by name and the raw body.
|
|
func roSchema(t *testing.T, svc *service, principal *bouncer.Principal) (int, map[string]map[string]any, []byte) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/notes/schema/form"), nil)
|
|
if principal != nil {
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
|
}
|
|
req.SetPathValue("vendor", "acme")
|
|
req.SetPathValue("plugin", "demo")
|
|
req.SetPathValue("controller", "notes")
|
|
rec := httptest.NewRecorder()
|
|
svc.formSchema(rec, req)
|
|
return rec.Code, roFields(t, rec.Body.Bytes()), rec.Body.Bytes()
|
|
}
|
|
|
|
// roFields indexes a form schema response's fields by name.
|
|
func roFields(t *testing.T, raw []byte) map[string]map[string]any {
|
|
t.Helper()
|
|
var body struct {
|
|
Data struct {
|
|
Fields []map[string]any `json:"fields"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
return nil
|
|
}
|
|
out := map[string]map[string]any{}
|
|
for _, field := range body.Data.Fields {
|
|
name, _ := field["name"].(string)
|
|
out[name] = field
|
|
}
|
|
return out
|
|
}
|
|
|
|
func roAdmin() *bouncer.Principal {
|
|
return &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
|
|
}
|
|
|
|
// roCaptureLog sends the default logger to a buffer for the test.
|
|
func roCaptureLog(t *testing.T) *bytes.Buffer {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
previous := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(previous) })
|
|
return &buf
|
|
}
|
|
|
|
// TestFieldTypeRequestOptions: a plugin field type's RequestOptions reach
|
|
// the served form schema per request, under the fields.yaml options, for
|
|
// every screen and for a form extension's field; an error fails open; a type
|
|
// without them serves the schema as before.
|
|
func TestFieldTypeRequestOptions(t *testing.T) {
|
|
t.Run("create, update and preview fields carry the request options", func(t *testing.T) {
|
|
counter := &roCounter{}
|
|
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
code, fields, raw := roSchema(t, &service{reg: reg}, roAdmin())
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", code, raw)
|
|
}
|
|
for _, name := range []string{"notes", "notes_create", "notes_update", "notes_preview"} {
|
|
opts, _ := fields[name]["typeOptions"].(map[string]any)
|
|
if !reflect.DeepEqual(opts["headingLevels"], []any{float64(2), float64(3), float64(4)}) {
|
|
t.Fatalf("%s typeOptions=%v", name, fields[name]["typeOptions"])
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("an option set in fields.yaml wins over the request value", func(t *testing.T) {
|
|
counter := &roCounter{}
|
|
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
_, fields, _ := roSchema(t, &service{reg: reg}, roAdmin())
|
|
if opts, _ := fields["notes"]["typeOptions"].(map[string]any); opts["rows"] != float64(4) {
|
|
t.Fatalf("typeOptions=%v, want rows 4 from fields.yaml", fields["notes"]["typeOptions"])
|
|
}
|
|
})
|
|
|
|
t.Run("RequestOptions runs once per schema request for each type", func(t *testing.T) {
|
|
counter := &roCounter{}
|
|
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
svc := &service{reg: reg}
|
|
roSchema(t, svc, roAdmin())
|
|
if counter.calls != 1 {
|
|
t.Fatalf("calls=%d after one request with four fields of the type, want 1", counter.calls)
|
|
}
|
|
roSchema(t, svc, roAdmin())
|
|
if counter.calls != 2 {
|
|
t.Fatalf("calls=%d after two requests, want 2", counter.calls)
|
|
}
|
|
})
|
|
|
|
t.Run("the compiled schema is never changed", func(t *testing.T) {
|
|
counter := &roCounter{}
|
|
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
cc, _ := reg.Get("acme.demo.notes")
|
|
before, _ := cc.Form.CanonicalJSON()
|
|
roSchema(t, &service{reg: reg}, roAdmin())
|
|
after, _ := cc.Form.CanonicalJSON()
|
|
if !bytes.Equal(before, after) || strings.Contains(string(after), "headingLevels") {
|
|
t.Fatalf("compiled schema changed:\n%s\n%s", before, after)
|
|
}
|
|
})
|
|
|
|
t.Run("an error serves the fields.yaml options and logs one warning", func(t *testing.T) {
|
|
buf := roCaptureLog(t)
|
|
counter := &roCounter{}
|
|
reg, err := ftCompile(roDemo(roNotesType(counter, errors.New("settings unavailable"))))
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
code, fields, raw := roSchema(t, &service{reg: reg}, roAdmin())
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", code, raw)
|
|
}
|
|
for _, name := range []string{"notes", "notes_create", "notes_update", "notes_preview"} {
|
|
if opts, _ := fields[name]["typeOptions"].(map[string]any); !reflect.DeepEqual(opts, map[string]any{"rows": float64(4)}) {
|
|
t.Fatalf("%s typeOptions=%v, want the fields.yaml options only", name, fields[name]["typeOptions"])
|
|
}
|
|
}
|
|
logged := buf.String()
|
|
if n := strings.Count(logged, "request options failed"); n != 1 || !strings.Contains(logged, "notes") || !strings.Contains(logged, "settings unavailable") {
|
|
t.Fatalf("want one warning naming the type, got %d:\n%s", n, logged)
|
|
}
|
|
if !strings.Contains(logged, "level=WARN") {
|
|
t.Fatalf("the warning is not at WARN level:\n%s", logged)
|
|
}
|
|
})
|
|
|
|
t.Run("a type without RequestOptions serves the schema unchanged", func(t *testing.T) {
|
|
reg, err := ftCompile(ftDemo(ftNotesField))
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
cc, _ := reg.Get("acme.demo.notes")
|
|
view, err := cc.Form.Localize(context.Background(), nil, dropdownProvider(cc.Controller))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want, _ := view.CanonicalJSON()
|
|
code, _, raw := roSchema(t, &service{reg: reg}, roAdmin())
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", code, raw)
|
|
}
|
|
var body struct {
|
|
Data FormView `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body.Data.Assets = ControllerAssets{}
|
|
view.Assets = ControllerAssets{}
|
|
got, _ := body.Data.CanonicalJSON()
|
|
want, _ = view.CanonicalJSON()
|
|
if !bytes.Equal(got, want) {
|
|
t.Fatalf("served fields differ from the compiled schema:\n%s\n%s", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("a form extension's plugin field gets its request options", func(t *testing.T) {
|
|
counter := &roCounter{}
|
|
ext := feExt("acme.extra", nil, "acme.blog.posts", "fields:\n note:\n type: extranote\n label: Note\n after: a\n mode2: on\n")
|
|
typ := feWidgetType()
|
|
typ.Options = []string{"mode2"}
|
|
typ.RequestOptions = func(context.Context, *gorm.DB) (map[string]any, error) {
|
|
counter.calls++
|
|
return map[string]any{"genericAnchors": []any{"here"}, "mode2": "request"}, nil
|
|
}
|
|
ext.fsys = fstest.MapFS{"assets/js/extra.js": &fstest.MapFile{Data: []byte("// extra\n")}}
|
|
ext.types = []pact.FormFieldType{typ}
|
|
reg, err := feCompile(feBlog(), ext)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
(&service{reg: reg}).formSchema(rec, feRequest(roAdmin(), http.MethodGet, adminAPI("/acme/blog/posts/schema/form"), nil, nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
opts, _ := roFields(t, rec.Body.Bytes())["note"]["typeOptions"].(map[string]any)
|
|
if !reflect.DeepEqual(opts["genericAnchors"], []any{"here"}) || opts["mode2"] != "on" || counter.calls != 1 {
|
|
t.Fatalf("typeOptions=%v calls=%d", opts, counter.calls)
|
|
}
|
|
})
|
|
|
|
t.Run("only the authenticated schema handler calls RequestOptions", func(t *testing.T) {
|
|
counter := &roCounter{}
|
|
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
code, _, raw := roSchema(t, &service{reg: reg}, nil)
|
|
if code != http.StatusUnauthorized || counter.calls != 0 {
|
|
t.Fatalf("status=%d calls=%d body=%s", code, counter.calls, raw)
|
|
}
|
|
code, _, _ = roSchema(t, &service{reg: reg}, &bouncer.Principal{ID: 2, Backend: true})
|
|
if code != http.StatusForbidden || counter.calls != 0 {
|
|
t.Fatalf("an administrator without the controller permission: status=%d calls=%d", code, counter.calls)
|
|
}
|
|
})
|
|
}
|