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:
Jakub Zych
2026-09-27 16:27:42 +02:00
parent c87148a34f
commit 9f296b0484
16 changed files with 1653 additions and 49 deletions

View File

@@ -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 {