Files
rudder-module-go/schema.go
T

233 lines
6.9 KiB
Go
Raw Permalink Normal View History

2026-09-24 17:59:23 +03:00
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
}