- 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
248 lines
8.0 KiB
Go
248 lines
8.0 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// ListSort is the compiled default sort. Direction is exactly asc or desc.
|
|
type ListSort struct {
|
|
Column string `json:"column"`
|
|
Direction string `json:"direction"`
|
|
}
|
|
|
|
// ListColumn is one compiled columns.yaml entry, in file order.
|
|
// Omitted sortable defaults to true, matching Winter; searchable defaults to false.
|
|
type ListColumn struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Searchable bool `json:"searchable"`
|
|
Sortable bool `json:"sortable"`
|
|
Type string `json:"type,omitempty"`
|
|
Relation string `json:"relation,omitempty"`
|
|
Select string `json:"select,omitempty"`
|
|
}
|
|
|
|
// ListFilter is one compiled config_filter scope. Values stay typed scalars.
|
|
type ListFilter struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label,omitempty"`
|
|
Type string `json:"type"`
|
|
Column string `json:"column,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
ModelClass string `json:"modelClass,omitempty"`
|
|
NameFrom string `json:"nameFrom,omitempty"`
|
|
TrueValue *jsonScalar `json:"trueValue,omitempty"`
|
|
FalseValue *jsonScalar `json:"falseValue,omitempty"`
|
|
Options []FormOption `json:"options,omitempty"`
|
|
}
|
|
|
|
// RowAction is a per-record link derived from recordUrl.
|
|
type RowAction struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
// BulkAction is a checkbox action. It carries no SQL.
|
|
type BulkAction struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label,omitempty"`
|
|
}
|
|
|
|
// ListSchema is the boot-compiled list contract for one controller.
|
|
// Labels stay as source keys until a request localizes a copy.
|
|
type ListSchema struct {
|
|
Title string `json:"title,omitempty"`
|
|
ModelClass string `json:"modelClass,omitempty"`
|
|
RecordURL string `json:"recordUrl,omitempty"`
|
|
NoRecordsMessage string `json:"noRecordsMessage,omitempty"`
|
|
RecordsPerPage int `json:"recordsPerPage"`
|
|
PerPageOptions []int `json:"perPageOptions"`
|
|
ShowSearch bool `json:"showSearch"`
|
|
ShowSetup bool `json:"showSetup"`
|
|
ShowCheckboxes bool `json:"showCheckboxes"`
|
|
ShowSorting bool `json:"showSorting"`
|
|
SearchTerm string `json:"searchTerm"`
|
|
SearchPrompt string `json:"searchPrompt,omitempty"`
|
|
DefaultSort *ListSort `json:"defaultSort,omitempty"`
|
|
ToolbarButtons []string `json:"toolbarButtons"`
|
|
Columns []ListColumn `json:"columns"`
|
|
Filters []ListFilter `json:"filters"`
|
|
RowActions []RowAction `json:"rowActions"`
|
|
BulkActions []BulkAction `json:"bulkActions"`
|
|
// Messages is the list's copy (D-13). The cached schema carries each
|
|
// phrase key as its own form; a response resolves them in its locale.
|
|
Messages *ListMessages `json:"messages"`
|
|
Meta *FormMeta `json:"meta,omitempty"`
|
|
|
|
messageKeys listMessageKeys
|
|
}
|
|
|
|
// MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null.
|
|
func (s ListSchema) MarshalJSON() ([]byte, error) {
|
|
if s.Messages == nil {
|
|
keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, s.listMessageKeySet())
|
|
s.Messages = &keys
|
|
}
|
|
type alias ListSchema
|
|
out := alias(s)
|
|
if out.PerPageOptions == nil {
|
|
out.PerPageOptions = []int{}
|
|
}
|
|
if out.ToolbarButtons == nil {
|
|
out.ToolbarButtons = []string{}
|
|
}
|
|
if out.Columns == nil {
|
|
out.Columns = []ListColumn{}
|
|
}
|
|
if out.Filters == nil {
|
|
out.Filters = []ListFilter{}
|
|
}
|
|
if out.RowActions == nil {
|
|
out.RowActions = []RowAction{}
|
|
}
|
|
if out.BulkActions == nil {
|
|
out.BulkActions = []BulkAction{}
|
|
}
|
|
return json.Marshal(out)
|
|
}
|
|
|
|
// FormSchema is the locale-neutral form contract compiled once at boot.
|
|
// Display strings stay as source keys until a request localizes a copy.
|
|
type FormSchema struct {
|
|
Name string `json:"name,omitempty"`
|
|
ModelClass string `json:"modelClass,omitempty"`
|
|
Fields []FormField `json:"fields"`
|
|
|
|
messageKeys formMessageKeys
|
|
redirects FormRedirects
|
|
}
|
|
|
|
// FormView is one request's localized form, including the locale actually used.
|
|
type FormView struct {
|
|
Name string `json:"name,omitempty"`
|
|
ModelClass string `json:"modelClass,omitempty"`
|
|
Fields []FormField `json:"fields"`
|
|
// Messages is the form's copy resolved in the request locale (D-13).
|
|
Messages FormMessages `json:"messages"`
|
|
// Redirects are the raw Winter config_form.yaml targets; the SPA maps
|
|
// them onto its routes.
|
|
Redirects FormRedirects `json:"redirects"`
|
|
Meta FormMeta `json:"meta"`
|
|
}
|
|
|
|
// FormRedirects are config_form.yaml defaultRedirect, create.* and update.*.
|
|
type FormRedirects struct {
|
|
Default string `json:"default"`
|
|
Create FormRedirect `json:"create"`
|
|
Update FormRedirect `json:"update"`
|
|
}
|
|
|
|
// FormRedirect is one Winter redirect pair.
|
|
type FormRedirect struct {
|
|
Redirect string `json:"redirect"`
|
|
RedirectClose string `json:"redirectClose"`
|
|
}
|
|
|
|
// FormMeta reports the locale selected for a schema response.
|
|
type FormMeta struct {
|
|
Locale string `json:"locale"`
|
|
}
|
|
|
|
// FormField is one Winter field in source order. JSON keys keep Winter spelling.
|
|
type FormField struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Label string `json:"label,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
Span string `json:"span,omitempty"`
|
|
Tab string `json:"tab,omitempty"`
|
|
Size string `json:"size,omitempty"`
|
|
Context *fieldContext `json:"context,omitempty"`
|
|
NameFrom string `json:"nameFrom,omitempty"`
|
|
EmptyOption string `json:"emptyOption,omitempty"`
|
|
Relation string `json:"relation,omitempty"`
|
|
Multiple bool `json:"multiple,omitempty"`
|
|
ReadOnly bool `json:"readOnly,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
Default *jsonScalar `json:"default,omitempty"`
|
|
Attributes map[string]jsonScalar `json:"attributes,omitempty"`
|
|
Options []FormOption `json:"options,omitempty"`
|
|
|
|
optionsMethod string
|
|
}
|
|
|
|
// FormOption is one dropdown choice. Value keeps the YAML scalar's JSON type.
|
|
type FormOption struct {
|
|
Value jsonScalar `json:"value"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// jsonScalar is a JSON scalar that still emits false, 0, and empty string.
|
|
type jsonScalar struct {
|
|
raw json.RawMessage
|
|
}
|
|
|
|
func (s jsonScalar) MarshalJSON() ([]byte, error) {
|
|
if len(s.raw) == 0 {
|
|
return []byte("null"), nil
|
|
}
|
|
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 := ""
|
|
if len(c.values) > 0 {
|
|
text = c.values[0]
|
|
}
|
|
return json.Marshal(text)
|
|
}
|
|
values := c.values
|
|
if values == nil {
|
|
values = []string{}
|
|
}
|
|
return json.Marshal(values)
|
|
}
|