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:
@@ -36,10 +36,9 @@ type SuccessMeta struct {
|
||||
LastPage int `json:"last_page,omitempty"`
|
||||
}
|
||||
|
||||
// SuccessEnvelope is the D-10 success envelope.
|
||||
type SuccessEnvelope struct {
|
||||
Data any `json:"data"`
|
||||
Meta SuccessMeta `json:"meta"`
|
||||
// AdminLogoutData is the POST /auth/logout payload.
|
||||
type AdminLogoutData struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// AdminLoginData is the admin login and refresh payload. Bearer transport
|
||||
@@ -139,8 +138,10 @@ func AdminLang() {}
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[AdminLogoutData]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /auth/logout [post]
|
||||
func AdminLogout() {}
|
||||
|
||||
@@ -152,6 +153,8 @@ func AdminLogout() {}
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} Envelope[AdminProfile]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /auth/me [get]
|
||||
func AdminMe() {}
|
||||
|
||||
@@ -164,6 +167,7 @@ func AdminMe() {}
|
||||
// @Success 200 {object} Envelope[[]NavigationEntry]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /navigation [get]
|
||||
func AdminNavigation() {}
|
||||
|
||||
@@ -173,9 +177,10 @@ func AdminNavigation() {}
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[[]SettingsEntry]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /settings [get]
|
||||
func AdminSettingsList() {}
|
||||
|
||||
@@ -186,7 +191,7 @@ func AdminSettingsList() {}
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param code path string true "Settings code"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[FormView]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
@@ -200,7 +205,7 @@ func AdminSettingsSchema() {}
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param code path string true "Settings code"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[SettingsResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
@@ -215,10 +220,11 @@ func AdminSettingsGet() {}
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param code path string true "Settings code"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[SettingsResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /settings/{code} [put]
|
||||
func AdminSettingsPut() {}
|
||||
|
||||
@@ -293,6 +299,24 @@ func AdminRelationSchema() {}
|
||||
// @Router /{vendor}/{plugin}/{controller}/fields/{field}/options [get]
|
||||
func AdminFieldOptions() {}
|
||||
|
||||
// AdminFilterOptions documents the model-backed filter options route (D-27).
|
||||
//
|
||||
// @Summary Filter scope options
|
||||
// @Description Choices of a declared scope filter of the controller's list, from the model's FilterOptions. {scope} is the filter name used as filter[<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.
|
||||
//
|
||||
// @Summary List admin records
|
||||
@@ -311,6 +335,7 @@ func AdminFieldOptions() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller} [get]
|
||||
func AdminList() {}
|
||||
|
||||
@@ -329,6 +354,7 @@ func AdminList() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller} [post]
|
||||
func AdminCreate() {}
|
||||
|
||||
@@ -342,10 +368,12 @@ func AdminCreate() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[BulkResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 409 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
|
||||
func AdminBulkDelete() {}
|
||||
|
||||
@@ -395,10 +423,11 @@ func AdminUpdate() {}
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Record id"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[BulkResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id} [delete]
|
||||
func AdminDelete() {}
|
||||
|
||||
@@ -413,9 +442,11 @@ func AdminDelete() {}
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} ListEnvelope[[]AdminRecord]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
|
||||
func AdminRelationLinked() {}
|
||||
|
||||
@@ -430,9 +461,11 @@ func AdminRelationLinked() {}
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} ListEnvelope[[]AdminRecord]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
|
||||
func AdminRelationCandidates() {}
|
||||
|
||||
@@ -448,10 +481,11 @@ func AdminRelationCandidates() {}
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
|
||||
func AdminRelationLink() {}
|
||||
|
||||
@@ -467,9 +501,10 @@ func AdminRelationLink() {}
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
|
||||
func AdminRelationUnlink() {}
|
||||
|
||||
@@ -265,7 +265,7 @@ func (s *service) logout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.logAuth(r, "success", id)
|
||||
s.expireSessionCookie(w)
|
||||
WriteData(w, http.StatusOK, map[string]any{"status": "logged_out"}, map[string]any{})
|
||||
WriteData(w, http.StatusOK, AdminLogoutData{Status: "logged_out"}, map[string]any{})
|
||||
}
|
||||
|
||||
// sessionToken returns the admin JWT the same way the backend guard reads it:
|
||||
|
||||
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 (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/towel"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
)
|
||||
|
||||
@@ -232,16 +234,66 @@ func validateFilter(ctl pact.AdminController, provider pact.FilterScope, filter
|
||||
if provider == nil {
|
||||
return fmt.Errorf("scope %s requires FilterScope", filter.Scope)
|
||||
}
|
||||
registered := false
|
||||
for _, name := range provider.FilterScopes() {
|
||||
if name == filter.Scope {
|
||||
return nil
|
||||
registered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("scope %s is not registered", filter.Scope)
|
||||
if !registered {
|
||||
return fmt.Errorf("scope %s is not registered", filter.Scope)
|
||||
}
|
||||
if _, ok := provider.(pact.FilterOptions); !ok {
|
||||
return fmt.Errorf("scope filter %s needs FilterOptions on the model to serve its choices (D-27)", filter.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilterOption is one model-backed filter choice (D-27). Values stay the
|
||||
// strings the model returns; they are sent back as filter[<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 {
|
||||
src, ok := ctl.(pact.AdminRecordSource)
|
||||
if !ok || src == nil {
|
||||
|
||||
@@ -262,6 +262,7 @@ func constrainNested(g pact.Router) {
|
||||
//
|
||||
// GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}
|
||||
// GET /{vendor}/{plugin}/{controller}/fields/{field}/options
|
||||
// GET /{vendor}/{plugin}/{controller}/filters/{scope}/options
|
||||
//
|
||||
// A numeric id never equals a literal segment, so the dispatch is unambiguous.
|
||||
// Anything else is the D-10 not_found envelope, as an unmatched API path.
|
||||
@@ -271,6 +272,9 @@ func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) {
|
||||
case id == "fields" && name == "options":
|
||||
r.SetPathValue("field", segment)
|
||||
s.fieldOptions(w, r)
|
||||
case id == "filters" && name == "options":
|
||||
r.SetPathValue("scope", segment)
|
||||
s.filterOptions(w, r)
|
||||
case segment == "relations":
|
||||
s.relationLinked(w, r)
|
||||
default:
|
||||
|
||||
@@ -540,6 +540,14 @@ type filterRow struct {
|
||||
|
||||
func (filterRow) FilterScopes() []string { return []string{"filterByGroup"} }
|
||||
|
||||
// FilterOptions serves the model-backed scope's choices (D-27).
|
||||
func (filterRow) FilterOptions(scope string) []pact.Option {
|
||||
if scope != "filterByGroup" {
|
||||
return nil
|
||||
}
|
||||
return []pact.Option{{Value: "4", Label: "demo::lang.group_four"}, {Value: "9", Label: "Group nine"}}
|
||||
}
|
||||
|
||||
func (filterRow) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
||||
if db == nil || name != "filterByGroup" {
|
||||
return db
|
||||
|
||||
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"} }
|
||||
|
||||
// FilterOptions serves the grouped scope's choices (D-27).
|
||||
func (queryRow) FilterOptions(scope string) []pact.Option {
|
||||
if scope != "filterByGroup" {
|
||||
return nil
|
||||
}
|
||||
return []pact.Option{{Value: "4", Label: "demo::lang.group_four"}, {Value: "9", Label: "Group nine"}}
|
||||
}
|
||||
|
||||
func (queryRow) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
||||
if db == nil || name != "filterByGroup" {
|
||||
return db
|
||||
@@ -426,3 +434,4 @@ func sameIDs(got, want []int) bool {
|
||||
var _ pact.AdminRecordSource = queryController{}
|
||||
var _ pact.AdminPermissioned = queryController{}
|
||||
var _ pact.FilterScope = queryRow{}
|
||||
var _ pact.FilterOptions = queryRow{}
|
||||
|
||||
@@ -3,6 +3,7 @@ package cabana
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ListSort is the compiled default sort. Direction is exactly asc or desc.
|
||||
@@ -191,12 +192,45 @@ func (s jsonScalar) MarshalJSON() ([]byte, error) {
|
||||
return s.raw, nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts exactly a JSON scalar (string, number, boolean or
|
||||
// null), so a served schema decodes back into its documented type.
|
||||
func (s *jsonScalar) UnmarshalJSON(raw []byte) error {
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
s.raw = nil
|
||||
case string, float64, bool:
|
||||
s.raw = append(json.RawMessage(nil), raw...)
|
||||
default:
|
||||
return fmt.Errorf("cabana: %s is not a JSON scalar", raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fieldContext preserves a single Winter context string or a source-ordered list.
|
||||
type fieldContext struct {
|
||||
single bool
|
||||
values []string
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts the two served shapes: a string or a string list.
|
||||
func (c *fieldContext) UnmarshalJSON(raw []byte) error {
|
||||
var single string
|
||||
if err := json.Unmarshal(raw, &single); err == nil {
|
||||
*c = fieldContext{single: true, values: []string{single}}
|
||||
return nil
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return fmt.Errorf("cabana: context must be a string or a list of strings")
|
||||
}
|
||||
*c = fieldContext{values: values}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c fieldContext) MarshalJSON() ([]byte, error) {
|
||||
if c.single {
|
||||
text := ""
|
||||
|
||||
@@ -46,6 +46,7 @@ var phase09Routes = []adminRoute{
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/form"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}"},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/fields/{field}/options", mounted: nestedGetRoute},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", mounted: nestedGetRoute},
|
||||
{key: "GET /{vendor}/{plugin}/{controller}"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}"},
|
||||
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
||||
@@ -271,6 +272,7 @@ func phase09ProtectedCalls() []phase09Call {
|
||||
{"relation-schema", (*service).relationSchema},
|
||||
{"relation-linked", (*service).relationLinked},
|
||||
{"field-options", (*service).fieldOptions},
|
||||
{"filter-options", (*service).filterOptions},
|
||||
{"nested-get", (*service).nestedGet},
|
||||
{"relation-candidates", (*service).relationCandidates},
|
||||
{"relation-link", (*service).relationLink},
|
||||
|
||||
Reference in New Issue
Block a user