Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions authbridge/authlib/pipeline/schema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Schema introspection for plugin config structs.
//
// Plugins decode their config from JSON via Configurable.Configure
// (see configurable.go). The framework has long known JSON field
// names (`json:"foo_bar"` tags) but nothing else about each field.
// Tools that want to render config UIs, generate templates, or
// publish JSON Schema have had to read source.
//
// SchemaOf walks a config struct via reflection and emits one
// FieldSchema per JSON-tagged field. Plugin authors annotate fields
// with three additional tags:
//
// required:"true" // boot fails if empty/zero
// description:"prose, one line" // shown in templates / hover
// default:"5000" // documented default (cosmetic)
// enum:"a,b,c" // allowed values
//
// All four tags are optional. Absence of any tag means "no
// metadata" — the field appears in the schema but with empty fields.
//
// This package owns the shape; presentation (template YAML, hover
// formatting, JSON Schema) lives in the consumer (abctl, kagenti UI,
// future generators).

package pipeline

import (
"reflect"
"strings"
)

// SchemaProvider is implemented by plugins whose config field
// metadata should appear in the catalog and downstream tooling
// (abctl edit templates, future kagenti-UI forms, JSON Schema
// generators). Plugins without configs (a2a-parser,
// inference-parser today) can omit this interface.
//
// The convention is a one-line method that delegates to SchemaOf:
//
// func (p *MyPlugin) ConfigSchema() []FieldSchema {
// return SchemaOf(myPluginConfig{})
// }
//
// The framework's catalog adapter type-asserts against this
// interface; absence is silently treated as "no field metadata."
type SchemaProvider interface {
ConfigSchema() []FieldSchema
}

// FieldSchema describes one config field's metadata for tooling.
type FieldSchema struct {
// Name is the JSON key (snake_case) — what operators type in YAML.
Name string `json:"name"`

// Type is a coarse-grained category sufficient to render templates
// and pick value placeholders. One of:
// "string", "int", "bool", "[]string", "object", "unknown".
// "object" indicates a nested struct whose fields populate Fields.
// "unknown" covers shapes the helper hasn't been taught (maps,
// slice-of-struct, etc.); the field still renders but without a
// type-specific placeholder.
Type string `json:"type"`

// Required reports the `required:"true"` tag. Boot semantics are
// the plugin's own concern — this field is just metadata.
Required bool `json:"required,omitempty"`

// Description is the `description:"..."` tag verbatim. Single-line.
Description string `json:"description,omitempty"`

// Default is the `default:"..."` tag verbatim. Cosmetic — the
// authoritative default is whatever applyDefaults sets at runtime.
Default string `json:"default,omitempty"`

// Enum is the `enum:"a,b,c"` tag split on commas. Empty when the
// field is not enum-shaped.
Enum []string `json:"enum,omitempty"`

// Fields is populated when Type is "object" (nested struct). The
// outer field's Description applies to the struct as a whole; the
// nested fields each carry their own metadata.
Fields []FieldSchema `json:"fields,omitempty"`
}

// SchemaOf walks the given struct value and returns its field schemas.
// Pass a zero value (e.g. `SchemaOf(ibacConfig{})`) — the value is
// inspected for type only, not for runtime field values.
//
// Returns nil if the argument isn't a struct (or pointer to struct).
// Fields without a `json:` tag are skipped (including untagged
// anonymous/embedded structs — unlike encoding/json, which promotes
// them). Explicit JSON tagging is the existing wire convention, and
// untagged fields don't appear in the operator-facing YAML, so they
// have nothing to surface in the schema either. No current plugin
// uses untagged embedding; if one ever needs that, this helper would
// need to grow flattening support.
func SchemaOf(configType any) []FieldSchema {
t := reflect.TypeOf(configType)
if t == nil {
return nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil
}
return schemaOfType(t, 0)
}

// maxSchemaDepth caps recursion in schemaOfType. Plugin configs in
// practice nest one or two levels (tokenexchange's identity + routes
// blocks are the deepest today); the cap is defensive against a
// future config struct with an inadvertent self-referential field
// (e.g. a *Self pointer) which would otherwise stack-overflow.
const maxSchemaDepth = 10

func schemaOfType(t reflect.Type, depth int) []FieldSchema {
if depth > maxSchemaDepth {
return nil
}
var out []FieldSchema
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
jsonTag := f.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
name := strings.SplitN(jsonTag, ",", 2)[0]
if name == "" || name == "-" {
continue
}
schema := FieldSchema{
Name: name,
Type: kindOf(f.Type),
Required: f.Tag.Get("required") == "true",
Description: f.Tag.Get("description"),
Default: f.Tag.Get("default"),
}
if enumTag := f.Tag.Get("enum"); enumTag != "" {
parts := strings.Split(enumTag, ",")
schema.Enum = make([]string, 0, len(parts))
for _, p := range parts {
if v := strings.TrimSpace(p); v != "" {
schema.Enum = append(schema.Enum, v)
}
}
}
if schema.Type == "object" {
// Recurse with a depth bound (see maxSchemaDepth). Plugin
// configs nest one or two levels in practice; the cap
// guards against an inadvertent self-referential field
// (e.g. *Self) silently blowing the stack.
schema.Fields = schemaOfType(unwrap(f.Type), depth+1)
}
out = append(out, schema)
}
return out
}

// kindOf returns the coarse-grained Type tag for a field.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The unbounded recursion note is well-documented, but consider adding a simple depth counter (e.g., max 10) as a safety net. A future plugin config struct with an inadvertent self-referential pointer field would stack overflow silently. Low risk today but cheap insurance.

func schemaOfType(t reflect.Type) []FieldSchema {
    return schemaOfTypeDepth(t, 0)
}

func schemaOfTypeDepth(t reflect.Type, depth int) []FieldSchema {
    if depth > 10 {
        return nil
    }
    // ... existing logic, recurse with depth+1
}

func kindOf(t reflect.Type) string {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Bool:
return "bool"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "int"
case reflect.Slice:
// Only []string gets a typed tag; other slices are "unknown"
// (slice-of-struct, slice-of-map, etc. are rare in plugin
// configs and don't render to a single YAML scalar template).
if t.Elem().Kind() == reflect.String {
return "[]string"
}
return "unknown"
case reflect.Struct:
return "object"
default:
return "unknown"
}
}

// unwrap returns the underlying struct type for a struct or *struct.
// Returns t unchanged if it's not a struct shape.
func unwrap(t reflect.Type) reflect.Type {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t
}
169 changes: 169 additions & 0 deletions authbridge/authlib/pipeline/schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package pipeline

import (
"reflect"
"testing"
)

// Tests below use representative shapes covering every Type the
// helper recognizes plus the no-tag and pointer-to-struct cases.
// They lock in the contract that consumers (abctl template
// renderer, JSON-Schema generator) build on.

type primitives struct {
Hostname string `json:"hostname" required:"true" description:"Target hostname."`
Port int `json:"port" description:"TCP port." default:"8080"`
Verbose bool `json:"verbose"`
Mode string `json:"mode" enum:"strict, lenient, off"`
}

func TestSchemaOf_Primitives(t *testing.T) {
got := SchemaOf(primitives{})
want := []FieldSchema{
{Name: "hostname", Type: "string", Required: true, Description: "Target hostname."},
{Name: "port", Type: "int", Description: "TCP port.", Default: "8080"},
{Name: "verbose", Type: "bool"},
{Name: "mode", Type: "string", Enum: []string{"strict", "lenient", "off"}},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("SchemaOf primitives:\n got: %+v\nwant: %+v", got, want)
}
}

type withList struct {
Allowed []string `json:"allowed" description:"Allowlisted hosts."`
}

func TestSchemaOf_StringSlice(t *testing.T) {
got := SchemaOf(withList{})
if len(got) != 1 || got[0].Type != "[]string" {
t.Errorf("expected one []string field, got %+v", got)
}
}

type nestedInner struct {
Path string `json:"path" required:"true" description:"Routes file path."`
Inner bool `json:"inline"`
}

type nestedOuter struct {
Name string `json:"name" required:"true"`
Inner nestedInner `json:"inner" description:"Nested config block."`
}

func TestSchemaOf_NestedStruct(t *testing.T) {
got := SchemaOf(nestedOuter{})
if len(got) != 2 {
t.Fatalf("expected 2 top-level fields, got %d (%+v)", len(got), got)
}
if got[1].Name != "inner" || got[1].Type != "object" {
t.Errorf("nested field shape wrong: %+v", got[1])
}
if len(got[1].Fields) != 2 {
t.Fatalf("nested fields not populated: %+v", got[1].Fields)
}
if got[1].Fields[0].Name != "path" || !got[1].Fields[0].Required {
t.Errorf("nested[0] shape wrong: %+v", got[1].Fields[0])
}
}

type withSkippable struct {
JSONOmitted string `json:"-"`
NoTag string
Counted int `json:"counted"`
}

func TestSchemaOf_SkipsUntaggedAndDashed(t *testing.T) {
// Skipping unexported fields is also part of the contract;
// we don't unit-test that case here because adding an
// unexported field with a json tag triggers `go vet`'s
// structtag check and pollutes the test file. The
// IsExported() guard in schemaOfType is straightforward.
got := SchemaOf(withSkippable{})
if len(got) != 1 || got[0].Name != "counted" {
t.Errorf("expected only the `counted` field, got %+v", got)
}
}

type withPointer struct {
Optional *int `json:"optional" description:"Optional override."`
}

func TestSchemaOf_PointerField(t *testing.T) {
got := SchemaOf(withPointer{})
if len(got) != 1 || got[0].Type != "int" {
t.Errorf("expected pointer-to-int to render as int, got %+v", got)
}
}

func TestSchemaOf_PointerToStruct(t *testing.T) {
got := SchemaOf(&primitives{})
if len(got) != 4 {
t.Errorf("expected SchemaOf to handle *struct, got %d fields", len(got))
}
}

func TestSchemaOf_NotAStruct(t *testing.T) {
if got := SchemaOf("not-a-struct"); got != nil {
t.Errorf("expected nil for non-struct, got %+v", got)
}
if got := SchemaOf(42); got != nil {
t.Errorf("expected nil for non-struct, got %+v", got)
}
if got := SchemaOf(nil); got != nil {
t.Errorf("expected nil for nil, got %+v", got)
}
}

type withSliceOfStruct struct {
Routes []nestedInner `json:"routes" description:"List of routes."`
}

func TestSchemaOf_SliceOfStructIsUnknown(t *testing.T) {
// We deliberately don't recurse into slice-of-struct — those
// shapes don't fit the per-line YAML-scalar template model
// abctl uses. Documented in kindOf comment.
got := SchemaOf(withSliceOfStruct{})
if len(got) != 1 || got[0].Type != "unknown" {
t.Errorf("slice-of-struct should be \"unknown\", got %+v", got)
}
}

// Empty-enum tag should produce nil, not [""]: the parser trims and
// drops empty parts.
func TestSchemaOf_EnumWhitespaceAndEmpty(t *testing.T) {
type withMessyEnum struct {
Mode string `json:"mode" enum:" on , , off "`
}
got := SchemaOf(withMessyEnum{})
if len(got) != 1 || !reflect.DeepEqual(got[0].Enum, []string{"on", "off"}) {
t.Errorf("expected trimmed [on, off], got %+v", got)
}
}

// Self-referential pointer fields should not stack-overflow — the
// depth bound in schemaOfType caps recursion. A linked-list-style
// node (Next *node) is the canonical case.
func TestSchemaOf_SelfReferentialIsBounded(t *testing.T) {
type node struct {
Name string `json:"name"`
Next *node `json:"next"`
}
// Should return without panicking and emit a finite schema.
got := SchemaOf(node{})
if len(got) != 2 {
t.Fatalf("expected 2 top-level fields, got %d", len(got))
}
// The recursion eventually returns nil at depth > maxSchemaDepth,
// so somewhere in the nested chain Fields is nil. Walk down and
// confirm we don't loop forever (the test itself would hang).
depth := 0
cur := got[1] // "next"
for cur.Type == "object" && len(cur.Fields) >= 2 {
depth++
if depth > maxSchemaDepth+5 {
t.Fatalf("recursion not bounded; reached depth %d", depth)
}
cur = cur.Fields[1]
}
}
Loading
Loading