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 }