233 lines
6.9 KiB
Go
233 lines
6.9 KiB
Go
package rudder
|
|
|
|
import (
|
|
"encoding"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type schema struct {
|
|
Type string `json:"type,omitempty"`
|
|
Format string `json:"format,omitempty"`
|
|
Nullable bool `json:"nullable,omitempty"`
|
|
Properties map[string]*schema `json:"properties,omitempty"`
|
|
Required []string `json:"required,omitempty"`
|
|
Items *schema `json:"items,omitempty"`
|
|
AdditionalProperties *schema `json:"additionalProperties,omitempty"`
|
|
Enum []any `json:"enum,omitempty"`
|
|
Minimum *float64 `json:"minimum,omitempty"`
|
|
Maximum *float64 `json:"maximum,omitempty"`
|
|
MinLength *uint64 `json:"minLength,omitempty"`
|
|
MaxLength *uint64 `json:"maxLength,omitempty"`
|
|
Pattern string `json:"pattern,omitempty"`
|
|
}
|
|
|
|
var (
|
|
timeType = reflect.TypeFor[time.Time]()
|
|
jsonMarshalerType = reflect.TypeFor[json.Marshaler]()
|
|
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
|
|
)
|
|
|
|
func mustSchema(label string, t reflect.Type) json.RawMessage {
|
|
body, err := json.Marshal(schemaOf(t, map[reflect.Type]bool{}))
|
|
if err != nil {
|
|
panic(fmt.Sprintf("rudder: %s schema: %v", label, err))
|
|
}
|
|
return body
|
|
}
|
|
|
|
func schemaOf(t reflect.Type, visiting map[reflect.Type]bool) *schema {
|
|
if t.Kind() == reflect.Pointer {
|
|
element := schemaOf(t.Elem(), visiting)
|
|
element.Nullable = true
|
|
return element
|
|
}
|
|
if t == timeType {
|
|
return &schema{Type: "string", Format: "date-time"}
|
|
}
|
|
if t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType) {
|
|
return &schema{}
|
|
}
|
|
if t.Implements(textMarshalerType) || reflect.PointerTo(t).Implements(textMarshalerType) {
|
|
return &schema{Type: "string"}
|
|
}
|
|
switch t.Kind() {
|
|
case reflect.Bool:
|
|
return &schema{Type: "boolean"}
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return &schema{Type: "integer"}
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
|
zero := 0.0
|
|
return &schema{Type: "integer", Minimum: &zero}
|
|
case reflect.Float32, reflect.Float64:
|
|
return &schema{Type: "number"}
|
|
case reflect.String:
|
|
return &schema{Type: "string"}
|
|
case reflect.Interface:
|
|
return &schema{}
|
|
case reflect.Slice:
|
|
if t.Elem().Kind() == reflect.Uint8 {
|
|
return &schema{Type: "string", Format: "byte"}
|
|
}
|
|
return &schema{Type: "array", Items: schemaOf(t.Elem(), visiting)}
|
|
case reflect.Array:
|
|
return &schema{Type: "array", Items: schemaOf(t.Elem(), visiting)}
|
|
case reflect.Map:
|
|
if t.Elem().Kind() == reflect.Interface {
|
|
return &schema{}
|
|
}
|
|
return &schema{Type: "object", AdditionalProperties: schemaOf(t.Elem(), visiting)}
|
|
case reflect.Struct:
|
|
object := &schema{Type: "object", Properties: map[string]*schema{}}
|
|
addFields(object, t, visiting)
|
|
slices.Sort(object.Required)
|
|
return object
|
|
}
|
|
panic(fmt.Sprintf("rudder: type %s cannot be encoded as JSON", t))
|
|
}
|
|
|
|
func addFields(object *schema, t reflect.Type, visiting map[reflect.Type]bool) {
|
|
if visiting[t] {
|
|
panic(fmt.Sprintf("rudder: recursive type %s is not supported in function arguments and results", t))
|
|
}
|
|
visiting[t] = true
|
|
defer delete(visiting, t)
|
|
var embedded []reflect.Type
|
|
for field := range t.Fields() {
|
|
tag := field.Tag.Get("json")
|
|
if tag == "-" {
|
|
continue
|
|
}
|
|
name, options, _ := strings.Cut(tag, ",")
|
|
fieldType := field.Type
|
|
if field.Anonymous && name == "" {
|
|
if fieldType.Kind() == reflect.Pointer {
|
|
fieldType = fieldType.Elem()
|
|
}
|
|
if fieldType.Kind() == reflect.Struct {
|
|
embedded = append(embedded, fieldType)
|
|
continue
|
|
}
|
|
}
|
|
if !field.IsExported() {
|
|
continue
|
|
}
|
|
if name == "" {
|
|
name = field.Name
|
|
}
|
|
property := schemaOf(fieldType, visiting)
|
|
applyConstraints(property, t.String()+"."+field.Name, field.Tag.Get("rudder"))
|
|
object.Properties[name] = property
|
|
if !strings.Contains(","+options+",", ",omitempty,") && !strings.Contains(","+options+",", ",omitzero,") {
|
|
object.Required = append(object.Required, name)
|
|
}
|
|
}
|
|
for _, embeddedType := range embedded {
|
|
inner := &schema{Properties: map[string]*schema{}}
|
|
addFields(inner, embeddedType, visiting)
|
|
required := map[string]bool{}
|
|
for _, name := range inner.Required {
|
|
required[name] = true
|
|
}
|
|
for name, property := range inner.Properties {
|
|
if _, ok := object.Properties[name]; ok {
|
|
continue
|
|
}
|
|
object.Properties[name] = property
|
|
if required[name] {
|
|
object.Required = append(object.Required, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func applyConstraints(property *schema, field string, tag string) {
|
|
for tag != "" {
|
|
var entry string
|
|
if strings.HasPrefix(tag, "pattern=") {
|
|
entry, tag = tag, ""
|
|
} else {
|
|
entry, tag, _ = strings.Cut(tag, ",")
|
|
}
|
|
key, value, _ := strings.Cut(entry, "=")
|
|
if err := applyConstraint(property, key, value); err != nil {
|
|
panic(fmt.Sprintf("rudder: invalid rudder tag on %s: %s: %v", field, entry, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func applyConstraint(property *schema, key string, value string) error {
|
|
numeric := property.Type == "integer" || property.Type == "number"
|
|
switch key {
|
|
case "min", "max":
|
|
if !numeric {
|
|
return fmt.Errorf("%s needs an integer or number field, got %q", key, property.Type)
|
|
}
|
|
bound, err := strconv.ParseFloat(value, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if key == "min" {
|
|
property.Minimum = &bound
|
|
} else {
|
|
property.Maximum = &bound
|
|
}
|
|
case "minLength", "maxLength":
|
|
if property.Type != "string" {
|
|
return fmt.Errorf("%s needs a string field, got %q", key, property.Type)
|
|
}
|
|
length, err := strconv.ParseUint(value, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if key == "minLength" {
|
|
property.MinLength = &length
|
|
} else {
|
|
property.MaxLength = &length
|
|
}
|
|
case "pattern":
|
|
if property.Type != "string" {
|
|
return fmt.Errorf("pattern needs a string field, got %q", property.Type)
|
|
}
|
|
if _, err := regexp.Compile(value); err != nil {
|
|
return err
|
|
}
|
|
property.Pattern = value
|
|
case "format":
|
|
if property.Type == "" || value == "" {
|
|
return fmt.Errorf("format needs a typed field and a value")
|
|
}
|
|
property.Format = value
|
|
case "enum":
|
|
for option := range strings.SplitSeq(value, "|") {
|
|
switch property.Type {
|
|
case "string":
|
|
property.Enum = append(property.Enum, option)
|
|
case "integer":
|
|
parsed, err := strconv.ParseInt(option, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
property.Enum = append(property.Enum, parsed)
|
|
case "number":
|
|
parsed, err := strconv.ParseFloat(option, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
property.Enum = append(property.Enum, parsed)
|
|
default:
|
|
return fmt.Errorf("enum needs a string, integer or number field, got %q", property.Type)
|
|
}
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown constraint %q; use min, max, minLength, maxLength, pattern, enum or format", key)
|
|
}
|
|
return nil
|
|
}
|