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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user