- 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
357 lines
9.8 KiB
Go
357 lines
9.8 KiB
Go
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"
|
|
)
|
|
|
|
var filterScopeKeys = map[string]struct{}{
|
|
"label": {}, "type": {}, "column": {}, "options": {},
|
|
"modelClass": {}, "nameFrom": {}, "scope": {},
|
|
}
|
|
|
|
type filterDocument struct {
|
|
Scopes scopeMap `yaml:"scopes"`
|
|
}
|
|
|
|
type scopeMap struct {
|
|
items []ListFilter
|
|
}
|
|
|
|
func compileFilters(pluginID string, ctl pact.AdminController, fsys fs.FS, dir, ref string) ([]ListFilter, error) {
|
|
file, err := filterAssetPath(pluginID, dir, ref)
|
|
if err != nil {
|
|
return nil, bootErr(pluginID, ctl.ID(), "config_filter.yaml", err)
|
|
}
|
|
raw, err := readAsset(fsys, file)
|
|
if err != nil {
|
|
return nil, bootErr(pluginID, ctl.ID(), file, err)
|
|
}
|
|
var doc filterDocument
|
|
if err := decodeStrict(raw, &doc); err != nil {
|
|
return nil, bootErr(pluginID, ctl.ID(), file, err)
|
|
}
|
|
filters := doc.Scopes.items
|
|
if filters == nil {
|
|
filters = []ListFilter{}
|
|
}
|
|
provider := filterProvider(ctl)
|
|
for _, filter := range filters {
|
|
if err := validateFilter(ctl, provider, filter); err != nil {
|
|
return nil, bootErr(pluginID, ctl.ID(), file, err)
|
|
}
|
|
}
|
|
return filters, nil
|
|
}
|
|
|
|
func filterAssetPath(pluginID, dir, ref string) (string, error) {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
return "", fmt.Errorf("filter file is empty")
|
|
}
|
|
if strings.Contains(ref, "..") {
|
|
return "", fmt.Errorf("filter path escapes the plugin")
|
|
}
|
|
if strings.HasPrefix(ref, "~/") || strings.Contains(ref, "/") {
|
|
return assetPath(pluginID, ref)
|
|
}
|
|
return path.Join(dir, ref), nil
|
|
}
|
|
|
|
func (m *scopeMap) UnmarshalYAML(node ast.Node) error {
|
|
node = unwrapNode(node)
|
|
if _, ok := node.(*ast.NullNode); ok || node == nil {
|
|
m.items = []ListFilter{}
|
|
return nil
|
|
}
|
|
mapping, ok := node.(*ast.MappingNode)
|
|
if !ok {
|
|
return fmt.Errorf("scopes must be a mapping")
|
|
}
|
|
items := make([]ListFilter, 0, len(mapping.Values))
|
|
seen := map[string]struct{}{}
|
|
for _, entry := range mapping.Values {
|
|
name, err := nodeString(unwrapNode(entry.Key))
|
|
if err != nil || !identifier(name) {
|
|
return fmt.Errorf("filter name %q is not an identifier", nodeText(entry.Key))
|
|
}
|
|
if _, dup := seen[name]; dup {
|
|
return fmt.Errorf("duplicate filter %s", name)
|
|
}
|
|
seen[name] = struct{}{}
|
|
filter, err := compileScopeNode(name, unwrapNode(entry.Value))
|
|
if err != nil {
|
|
return fmt.Errorf("filter %s: %w", name, err)
|
|
}
|
|
items = append(items, filter)
|
|
}
|
|
m.items = items
|
|
return nil
|
|
}
|
|
|
|
func compileScopeNode(name string, node ast.Node) (ListFilter, error) {
|
|
mapping, ok := node.(*ast.MappingNode)
|
|
if !ok {
|
|
return ListFilter{}, fmt.Errorf("must be a mapping")
|
|
}
|
|
values := map[string]ast.Node{}
|
|
for _, entry := range mapping.Values {
|
|
key, err := nodeString(unwrapNode(entry.Key))
|
|
if err != nil {
|
|
return ListFilter{}, err
|
|
}
|
|
if key == "conditions" {
|
|
return ListFilter{}, fmt.Errorf("conditions is not supported; use a column or a registered model scope")
|
|
}
|
|
if _, known := filterScopeKeys[key]; !known {
|
|
return ListFilter{}, fmt.Errorf("unknown field %s", key)
|
|
}
|
|
if _, dup := values[key]; dup {
|
|
return ListFilter{}, fmt.Errorf("duplicate field %s", key)
|
|
}
|
|
values[key] = unwrapNode(entry.Value)
|
|
}
|
|
filter := ListFilter{Name: name}
|
|
var err error
|
|
if node, ok := values["label"]; ok {
|
|
filter.Label, err = nodeString(node)
|
|
if err != nil {
|
|
return ListFilter{}, fmt.Errorf("label: %w", err)
|
|
}
|
|
}
|
|
if node, ok := values["type"]; ok {
|
|
filter.Type, err = nodeString(node)
|
|
if err != nil {
|
|
return ListFilter{}, fmt.Errorf("type: %w", err)
|
|
}
|
|
}
|
|
if node, ok := values["column"]; ok {
|
|
filter.Column, err = nodeString(node)
|
|
if err != nil || !identifier(filter.Column) {
|
|
return ListFilter{}, fmt.Errorf("column %q is not an identifier", filter.Column)
|
|
}
|
|
}
|
|
if node, ok := values["modelClass"]; ok {
|
|
filter.ModelClass, err = nodeString(node)
|
|
if err != nil || strings.TrimSpace(filter.ModelClass) == "" {
|
|
return ListFilter{}, fmt.Errorf("modelClass is empty")
|
|
}
|
|
}
|
|
if node, ok := values["nameFrom"]; ok {
|
|
filter.NameFrom, err = nodeString(node)
|
|
if err != nil || !identifier(filter.NameFrom) {
|
|
return ListFilter{}, fmt.Errorf("nameFrom %q is not an identifier", filter.NameFrom)
|
|
}
|
|
}
|
|
if node, ok := values["scope"]; ok {
|
|
filter.Scope, err = nodeString(node)
|
|
if err != nil || !identifier(filter.Scope) {
|
|
return ListFilter{}, fmt.Errorf("scope %q is not an identifier", filter.Scope)
|
|
}
|
|
}
|
|
if node, ok := values["options"]; ok {
|
|
filter.Options, err = compileFilterOptions(node)
|
|
if err != nil {
|
|
return ListFilter{}, fmt.Errorf("options: %w", err)
|
|
}
|
|
}
|
|
if filter.Type == "" && filter.Scope != "" {
|
|
filter.Type = "scope"
|
|
}
|
|
switch filter.Type {
|
|
case "switch":
|
|
if filter.Column == "" {
|
|
return ListFilter{}, fmt.Errorf("column is required")
|
|
}
|
|
if len(filter.Options) == 0 {
|
|
on := jsonScalar{raw: []byte("true")}
|
|
off := jsonScalar{raw: []byte("false")}
|
|
filter.TrueValue = &on
|
|
filter.FalseValue = &off
|
|
}
|
|
case "daterange":
|
|
if filter.Column == "" {
|
|
return ListFilter{}, fmt.Errorf("column is required")
|
|
}
|
|
case "scope":
|
|
if filter.Scope == "" || filter.ModelClass == "" || filter.NameFrom == "" {
|
|
return ListFilter{}, fmt.Errorf("scope, modelClass, and nameFrom are required")
|
|
}
|
|
default:
|
|
if filter.Type == "" {
|
|
return ListFilter{}, fmt.Errorf("type is required")
|
|
}
|
|
return ListFilter{}, fmt.Errorf("unsupported filter type %s", filter.Type)
|
|
}
|
|
return filter, nil
|
|
}
|
|
|
|
func compileFilterOptions(node ast.Node) ([]FormOption, error) {
|
|
mapping, ok := node.(*ast.MappingNode)
|
|
if !ok {
|
|
return nil, fmt.Errorf("must be a map")
|
|
}
|
|
options := make([]FormOption, 0, len(mapping.Values))
|
|
for _, entry := range mapping.Values {
|
|
value, err := nodeScalar(unwrapNode(entry.Key))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("value: %w", err)
|
|
}
|
|
label, err := nodeString(unwrapNode(entry.Value))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("label: %w", err)
|
|
}
|
|
options = append(options, FormOption{Value: value, Label: label})
|
|
}
|
|
return options, nil
|
|
}
|
|
|
|
func validateFilter(ctl pact.AdminController, provider pact.FilterScope, filter ListFilter) error {
|
|
switch filter.Type {
|
|
case "switch", "daterange":
|
|
if _, _, check := listModelContract(ctl); check {
|
|
ft, ok := modelColumnType(ctl, filter.Column)
|
|
if !ok {
|
|
return fmt.Errorf("column %s is not on the model", filter.Column)
|
|
}
|
|
if filter.Type == "switch" && !isBoolType(ft) {
|
|
return fmt.Errorf("column %s is not a boolean", filter.Column)
|
|
}
|
|
if filter.Type == "daterange" && !isTimeType(ft) {
|
|
return fmt.Errorf("column %s is not a date", filter.Column)
|
|
}
|
|
}
|
|
case "scope":
|
|
if provider == nil {
|
|
return fmt.Errorf("scope %s requires FilterScope", filter.Scope)
|
|
}
|
|
registered := false
|
|
for _, name := range provider.FilterScopes() {
|
|
if name == filter.Scope {
|
|
registered = true
|
|
break
|
|
}
|
|
}
|
|
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 {
|
|
return nil
|
|
}
|
|
rec := src.NewRecord()
|
|
provider, ok := rec.(pact.FilterScope)
|
|
if !ok || provider == nil {
|
|
return nil
|
|
}
|
|
return provider
|
|
}
|
|
|
|
func modelColumnType(ctl pact.AdminController, column string) (reflect.Type, bool) {
|
|
src, ok := ctl.(pact.AdminRecordSource)
|
|
if !ok || src == nil || src.NewRecord() == nil {
|
|
return nil, false
|
|
}
|
|
t := reflect.TypeOf(src.NewRecord())
|
|
for t != nil && t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
if t == nil || t.Kind() != reflect.Struct {
|
|
return nil, false
|
|
}
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
if field.PkgPath != "" || isListRelation(field.Type) {
|
|
continue
|
|
}
|
|
name := gormColumn(field)
|
|
if name == "" {
|
|
name = field.Name
|
|
}
|
|
if name == column {
|
|
return field.Type, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func isTimeType(t reflect.Type) bool {
|
|
if t == nil {
|
|
return false
|
|
}
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
return t == reflect.TypeOf(time.Time{})
|
|
}
|
|
|
|
func isBoolType(t reflect.Type) bool {
|
|
if t == nil {
|
|
return false
|
|
}
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
return t.Kind() == reflect.Bool
|
|
}
|