feat: add HTTP migration bridges and extensible API binding
This commit is contained in:
@@ -23,10 +23,12 @@ var valuesValidator = validator.New()
|
||||
|
||||
// Binder is immutable after compilation and safe for concurrent requests.
|
||||
type Binder struct {
|
||||
root *node
|
||||
typ reflect.Type
|
||||
root *node
|
||||
typ reflect.Type
|
||||
schemaOnly bool
|
||||
}
|
||||
type node struct {
|
||||
scalar *Scalar
|
||||
typ reflect.Type
|
||||
schema *openapi.Schema
|
||||
fields []field
|
||||
@@ -42,9 +44,11 @@ type field struct {
|
||||
binding string
|
||||
}
|
||||
|
||||
func New[T any]() (*Binder, error) { return ForType(reflect.TypeOf((*T)(nil)).Elem()) }
|
||||
func MustNew[T any]() *Binder {
|
||||
b, err := New[T]()
|
||||
func New[T any](scalars ...Scalar) (*Binder, error) {
|
||||
return ForType(reflect.TypeOf((*T)(nil)).Elem(), scalars...)
|
||||
}
|
||||
func MustNew[T any](scalars ...Scalar) *Binder {
|
||||
b, err := New[T](scalars...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -52,20 +56,36 @@ func MustNew[T any]() *Binder {
|
||||
}
|
||||
|
||||
// ForType also lets generators check the exact runtime contract before writing files.
|
||||
// The root must be a struct; recursive and custom-marshaling types are rejected.
|
||||
func ForType(t reflect.Type) (*Binder, error) {
|
||||
// The root must be a struct; custom marshalers require explicit scalar registration.
|
||||
func ForType(t reflect.Type, scalars ...Scalar) (*Binder, error) { return build(t, false, scalars) }
|
||||
|
||||
// Describe compiles schemas without loading or executing application Go types.
|
||||
// The returned Binder cannot bind requests; it is for generators only.
|
||||
func Describe(t reflect.Type, scalars ...Scalar) (*Binder, error) { return build(t, true, scalars) }
|
||||
|
||||
func build(t reflect.Type, schemaOnly bool, scalars []Scalar) (*Binder, error) {
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil, errors.New("contract: root must be a struct")
|
||||
}
|
||||
c := compiler{scalars: map[reflect.Type]Scalar{}}
|
||||
for _, scalar := range scalars {
|
||||
if err := scalar.validate(schemaOnly); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := c.scalars[scalar.Type]; exists {
|
||||
return nil, fmt.Errorf("contract: duplicate scalar type %s", scalar.Type)
|
||||
}
|
||||
c.scalars[scalar.Type] = scalar
|
||||
}
|
||||
budget := 10000
|
||||
n, err := compile(t, true, map[reflect.Type]bool{}, 0, &budget)
|
||||
n, err := c.compile(t, true, map[reflect.Type]bool{}, 0, &budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Binder{root: n, typ: t}, nil
|
||||
return &Binder{root: n, typ: t, schemaOnly: schemaOnly}, nil
|
||||
}
|
||||
|
||||
func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int, budget *int) (*node, error) {
|
||||
func (c compiler) compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int, budget *int) (*node, error) {
|
||||
*budget--
|
||||
if *budget < 0 {
|
||||
return nil, errors.New("contract: schema expansion exceeds 10000 nodes")
|
||||
@@ -79,21 +99,32 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
stack[t] = true
|
||||
defer delete(stack, t)
|
||||
n := &node{typ: t, schema: &openapi.Schema{}}
|
||||
if scalar, ok := c.scalars[t]; ok {
|
||||
if root {
|
||||
return nil, errors.New("contract: root cannot be a scalar")
|
||||
}
|
||||
n.scalar = &scalar
|
||||
n.schema = scalar.inputSchema()
|
||||
return n, nil
|
||||
}
|
||||
// Wire behavior must be described completely by these tags.
|
||||
marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem()
|
||||
unmarshaler := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()
|
||||
if t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) || t.Implements(unmarshaler) || reflect.PointerTo(t).Implements(unmarshaler) {
|
||||
return nil, errors.New("contract: custom JSON marshalers are unsupported")
|
||||
if t.Kind() != reflect.Pointer && (t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) || t.Implements(unmarshaler) || reflect.PointerTo(t).Implements(unmarshaler)) {
|
||||
return nil, errors.New("contract: custom JSON marshalers require explicit Scalar registration")
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Interface:
|
||||
if t.NumMethod() != 0 {
|
||||
return nil, errors.New("contract: only any / interface{} is supported")
|
||||
}
|
||||
case reflect.Pointer:
|
||||
e, err := compile(t.Elem(), false, stack, depth+1, budget)
|
||||
e, err := c.compile(t.Elem(), false, stack, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.elem = e
|
||||
*n.schema = *e.schema
|
||||
n.schema.Nullable = true
|
||||
n.schema = nullableSchema(e.schema)
|
||||
case reflect.String:
|
||||
n.schema.Type = "string"
|
||||
case reflect.Bool:
|
||||
@@ -132,10 +163,10 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
if t.Kind() == reflect.Map && t.Key().Kind() != reflect.String {
|
||||
return nil, errors.New("contract: map keys must be strings")
|
||||
}
|
||||
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {
|
||||
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 && c.scalars[t.Elem()].Type == nil {
|
||||
return nil, errors.New("contract: byte slices use base64 in Go; use string for binary data")
|
||||
}
|
||||
e, err := compile(t.Elem(), false, stack, depth+1, budget)
|
||||
e, err := c.compile(t.Elem(), false, stack, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,7 +189,7 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
if sf.PkgPath != "" || sf.Anonymous {
|
||||
return nil, fmt.Errorf("contract: %s must be an exported named field", sf.Name)
|
||||
}
|
||||
f, err := compileField(sf, root, stack, depth+1, budget)
|
||||
f, err := c.compileField(sf, root, stack, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contract: %s: %w", sf.Name, err)
|
||||
}
|
||||
@@ -182,7 +213,7 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool, depth int, budget *int) (field, error) {
|
||||
func (c compiler) compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool, depth int, budget *int) (field, error) {
|
||||
f := field{}
|
||||
// Reject typos and unsupported tags instead of silently losing validation.
|
||||
tags, err := ParseTags(string(sf.Tag))
|
||||
@@ -218,11 +249,37 @@ func compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool
|
||||
if (f.source == "header" && !headerName.MatchString(f.name)) || (f.source != "header" && !wireName.MatchString(f.name)) {
|
||||
return f, errors.New("wire names must be lowerCamelCase (headers use HTTP names)")
|
||||
}
|
||||
n, err := compile(sf.Type, false, stack, depth, budget)
|
||||
n, err := c.compile(sf.Type, false, stack, depth, budget)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.value = n
|
||||
opaque := base(n).scalar
|
||||
isList := base(n).typ.Kind() == reflect.Slice && opaque == nil
|
||||
if isList {
|
||||
opaque = base(base(n).elem).scalar
|
||||
}
|
||||
if opaque != nil {
|
||||
if isList && (f.source == "path" || f.source == "header") {
|
||||
return f, errors.New("path/header values must be scalars")
|
||||
}
|
||||
if f.source != "json" && !opaque.Text {
|
||||
return f, errors.New("custom scalar needs text: true and UnmarshalText for path/query/header")
|
||||
}
|
||||
if f.binding != "" {
|
||||
return f, errors.New("custom scalar constraints belong in its decoder; use optional/pointer for presence and null")
|
||||
}
|
||||
for _, option := range parts[1:] {
|
||||
if option != "optional" && option != "omitempty" {
|
||||
return f, errors.New("custom scalar supports only optional/omitempty field options")
|
||||
}
|
||||
f.optional = true
|
||||
}
|
||||
if f.source == "path" && (f.optional || sf.Type.Kind() == reflect.Pointer) {
|
||||
return f, errors.New("path fields must be required non-pointer values")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
scalar := base(n).typ.Kind()
|
||||
if f.source != "json" && !isScalar(scalar) && !(scalar == reflect.Slice && isScalar(base(base(n).elem).typ.Kind())) {
|
||||
return f, errors.New("path/query/header values must be scalars or scalar slices")
|
||||
@@ -370,7 +427,7 @@ func (b *Binder) ResponseSchema() (*openapi.Schema, error) {
|
||||
return nil, errors.New("contract: response fields must use json tags")
|
||||
}
|
||||
}
|
||||
return copySchema(b.root.schema), nil
|
||||
return responseSchema(b.root), nil
|
||||
}
|
||||
func (b *Binder) Parameters() []openapi.Parameter {
|
||||
var out []openapi.Parameter
|
||||
@@ -382,7 +439,13 @@ func (b *Binder) Parameters() []openapi.Parameter {
|
||||
if in == "form" {
|
||||
in = "query"
|
||||
}
|
||||
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: copySchema(f.value.schema)})
|
||||
schema := copySchema(f.value.schema)
|
||||
if base(f.value).scalar != nil {
|
||||
schema = &openapi.Schema{Type: "string"}
|
||||
} else if base(f.value).typ.Kind() == reflect.Slice && base(base(f.value).elem).scalar != nil {
|
||||
schema.Items = &openapi.Schema{Type: "string"}
|
||||
}
|
||||
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: schema})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user