// Package schema provides JSON Schema generation from Go structs. // It produces standard JSON Schema as map[string]any, with no provider-specific types. package schema import ( "reflect" "strings" ) // FromStruct generates a JSON Schema object from a Go struct. // Struct tags used: // - `json:"name"` — sets the property name (standard Go JSON convention) // - `description:"..."` — sets the property description // - `enum:"a,b,c"` — restricts string values to the given set // // Pointer fields are treated as optional; non-pointer fields are required. // Anonymous (embedded) struct fields are flattened into the parent. func FromStruct(v any) map[string]any { t := reflect.TypeOf(v) if t.Kind() == reflect.Ptr { t = t.Elem() } if t.Kind() != reflect.Struct { panic("schema.FromStruct expects a struct or pointer to struct") } return objectSchema(t) } func objectSchema(t reflect.Type) map[string]any { properties := map[string]any{} var required []string for i := 0; i < t.NumField(); i++ { field := t.Field(i) // Skip unexported fields if !field.IsExported() { continue } // Flatten anonymous (embedded) structs if field.Anonymous { ft := field.Type if ft.Kind() == reflect.Ptr { ft = ft.Elem() } if ft.Kind() == reflect.Struct { embedded := objectSchema(ft) if props, ok := embedded["properties"].(map[string]any); ok { for k, v := range props { properties[k] = v } } if req, ok := embedded["required"].([]string); ok { required = append(required, req...) } } continue } name := fieldName(field) isRequired := true ft := field.Type if ft.Kind() == reflect.Ptr { ft = ft.Elem() isRequired = false } prop := fieldSchema(field, ft) properties[name] = prop if isRequired { required = append(required, name) } } result := map[string]any{ "type": "object", "properties": properties, } if len(required) > 0 { result["required"] = required } return result } func fieldSchema(field reflect.StructField, ft reflect.Type) map[string]any { prop := map[string]any{} // Check for enum tag first if enumTag, ok := field.Tag.Lookup("enum"); ok { vals := parseEnum(enumTag) prop["type"] = "string" prop["enum"] = vals if desc, ok := field.Tag.Lookup("description"); ok { prop["description"] = desc } return prop } switch ft.Kind() { case reflect.String: prop["type"] = "string" case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: prop["type"] = "integer" case reflect.Float32, reflect.Float64: prop["type"] = "number" case reflect.Bool: prop["type"] = "boolean" case reflect.Struct: return objectSchema(ft) case reflect.Slice: prop["type"] = "array" elemType := ft.Elem() if elemType.Kind() == reflect.Ptr { elemType = elemType.Elem() } prop["items"] = typeSchema(elemType) case reflect.Map: prop["type"] = "object" if ft.Key().Kind() == reflect.String { valType := ft.Elem() if valType.Kind() == reflect.Ptr { valType = valType.Elem() } prop["additionalProperties"] = typeSchema(valType) } default: prop["type"] = "string" // fallback } if desc, ok := field.Tag.Lookup("description"); ok { prop["description"] = desc } return prop } func typeSchema(t reflect.Type) map[string]any { switch t.Kind() { case reflect.String: return map[string]any{"type": "string"} case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return map[string]any{"type": "integer"} case reflect.Float32, reflect.Float64: return map[string]any{"type": "number"} case reflect.Bool: return map[string]any{"type": "boolean"} case reflect.Struct: return objectSchema(t) case reflect.Slice: elemType := t.Elem() if elemType.Kind() == reflect.Ptr { elemType = elemType.Elem() } return map[string]any{ "type": "array", "items": typeSchema(elemType), } default: return map[string]any{"type": "string"} } } func fieldName(f reflect.StructField) string { if tag, ok := f.Tag.Lookup("json"); ok { parts := strings.SplitN(tag, ",", 2) if parts[0] != "" && parts[0] != "-" { return parts[0] } } return f.Name } func parseEnum(tag string) []string { parts := strings.Split(tag, ",") var vals []string for _, p := range parts { p = strings.TrimSpace(p) if p != "" { vals = append(vals, p) } } return vals }