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,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 := ""