| 1 | package protocol |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "reflect" |
| 7 | "sort" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | ) |
| 12 | |
| 13 | // SchemaDraft202012 is the JSON Schema dialect of the generated document. |
| 14 | const SchemaDraft202012 = "https://json-schema.org/draft/2020-12/schema" |
| 15 | |
| 16 | // SchemaTitle is the generated document's human title. |
| 17 | const SchemaTitle = "Reasonix Extension Protocol v1" |
| 18 | |
| 19 | var rawMessageType = reflect.TypeOf(json.RawMessage{}) |
| 20 | |
| 21 | // BuildSchemaDocument reflection-walks the frozen registry and produces the |
| 22 | // canonical JSON Schema (draft 2020-12) document: one methods object keyed by |
| 23 | // sorted method name and one $defs entry per wire DTO. The returned map |
| 24 | // marshals deterministically because encoding/json sorts map keys. |
| 25 | func BuildSchemaDocument() (map[string]any, error) { |
| 26 | defs := map[string]any{} |
| 27 | methods := map[string]any{} |
| 28 | for _, spec := range Registry() { |
| 29 | paramsRef, err := buildJSONSchema(defs, spec.ParamsType) |
| 30 | if err != nil { |
| 31 | return nil, fmt.Errorf("%s params: %w", spec.Name, err) |
| 32 | } |
| 33 | var result any |
| 34 | if spec.Notification() { |
| 35 | result = nil |
| 36 | } else { |
| 37 | result, err = buildJSONSchema(defs, spec.ResultType) |
| 38 | if err != nil { |
| 39 | return nil, fmt.Errorf("%s result: %w", spec.Name, err) |
| 40 | } |
| 41 | } |
| 42 | methods[string(spec.Name)] = map[string]any{ |
| 43 | "direction": string(spec.Direction), |
| 44 | "class": string(spec.Class), |
| 45 | "params": paramsRef, |
| 46 | "result": result, |
| 47 | "notification": spec.Notification(), |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | limits := FrozenLimits() |
| 52 | errors := make([]any, 0, len(frozenErrorSpecs)) |
| 53 | for _, contract := range ErrorContracts() { |
| 54 | errors = append(errors, map[string]any{ |
| 55 | "reason": string(contract.Reason), |
| 56 | "jsonRpcCode": contract.JSONRPCCode, |
| 57 | "message": contract.Message, |
| 58 | "retryable": contract.Retryable, |
| 59 | }) |
| 60 | } |
| 61 | interceptEvents := InterceptEvents() |
| 62 | events := make([]any, len(interceptEvents)) |
| 63 | for i, event := range interceptEvents { |
| 64 | events[i] = event |
| 65 | } |
| 66 | |
| 67 | return map[string]any{ |
| 68 | "$schema": SchemaDraft202012, |
| 69 | "$id": ProtocolID, |
| 70 | "title": SchemaTitle, |
| 71 | "protocol": ProtocolID, |
| 72 | "protocolID": ProtocolID, |
| 73 | "protocolMajor": ProtocolMajor, |
| 74 | "limits": map[string]any{ |
| 75 | "frameBytes": limits.FrameBytes, |
| 76 | "externalizeFieldBytes": limits.ExternalizeFieldBytes, |
| 77 | "contentRefChunkBytes": limits.ContentRefChunkBytes, |
| 78 | "contentRefObjectBytes": limits.ContentRefObjectBytes, |
| 79 | }, |
| 80 | "interceptEvents": events, |
| 81 | "errors": errors, |
| 82 | "methods": methods, |
| 83 | "$defs": defs, |
| 84 | }, nil |
| 85 | } |
| 86 | |
| 87 | // buildJSONSchema maps a Go wire type to a JSON Schema. Named structs become |
| 88 | // $defs entries referenced by name; unconstrained JSON (json.RawMessage, any) |
| 89 | // becomes the boolean schema true. |
| 90 | func buildJSONSchema(defs map[string]any, typ reflect.Type) (any, error) { |
| 91 | for typ.Kind() == reflect.Pointer { |
| 92 | typ = typ.Elem() |
| 93 | } |
| 94 | if typ == rawMessageType { |
| 95 | return true, nil |
| 96 | } |
| 97 | if allowed, ok := enumTypes[typ]; ok { |
| 98 | values := append([]string(nil), allowed...) |
| 99 | sort.Strings(values) |
| 100 | enum := make([]any, len(values)) |
| 101 | for i, value := range values { |
| 102 | enum[i] = value |
| 103 | } |
| 104 | return map[string]any{"type": "string", "enum": enum}, nil |
| 105 | } |
| 106 | switch typ.Kind() { |
| 107 | case reflect.Struct: |
| 108 | name := typ.Name() |
| 109 | if name == "" { |
| 110 | return nil, fmt.Errorf("anonymous struct %v is not a named wire DTO", typ) |
| 111 | } |
| 112 | if _, registered := defs[name]; !registered { |
| 113 | // Reserve the name before walking fields so self-referencing DTOs |
| 114 | // terminate instead of recursing forever. |
| 115 | defs[name] = true |
| 116 | object, err := buildObjectSchema(defs, typ) |
| 117 | if err != nil { |
| 118 | return nil, fmt.Errorf("$defs.%s: %w", name, err) |
| 119 | } |
| 120 | defs[name] = object |
| 121 | } |
| 122 | return map[string]any{"$ref": "#/$defs/" + name}, nil |
| 123 | case reflect.Slice, reflect.Array: |
| 124 | if typ.Elem().Kind() == reflect.Uint8 { |
| 125 | return map[string]any{"type": "string"}, nil |
| 126 | } |
| 127 | items, err := buildJSONSchema(defs, typ.Elem()) |
| 128 | if err != nil { |
| 129 | return nil, err |
| 130 | } |
| 131 | return map[string]any{"type": "array", "items": items}, nil |
| 132 | case reflect.String: |
| 133 | return map[string]any{"type": "string"}, nil |
| 134 | case reflect.Bool: |
| 135 | return map[string]any{"type": "boolean"}, nil |
| 136 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, |
| 137 | reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: |
| 138 | return map[string]any{"type": "integer"}, nil |
| 139 | case reflect.Float32, reflect.Float64: |
| 140 | return map[string]any{"type": "number"}, nil |
| 141 | case reflect.Map: |
| 142 | if typ.Key().Kind() != reflect.String { |
| 143 | return nil, fmt.Errorf("unsupported wire map key type %v", typ.Key()) |
| 144 | } |
| 145 | additional, err := buildJSONSchema(defs, typ.Elem()) |
| 146 | if err != nil { |
| 147 | return nil, err |
| 148 | } |
| 149 | return map[string]any{"type": "object", "additionalProperties": additional}, nil |
| 150 | case reflect.Interface: |
| 151 | if typ.NumMethod() == 0 { |
| 152 | return true, nil |
| 153 | } |
| 154 | } |
| 155 | return nil, fmt.Errorf("unsupported wire type %v", typ) |
| 156 | } |
| 157 | |
| 158 | // buildObjectSchema renders one named DTO struct as a closed JSON Schema |
| 159 | // object: properties sorted (by map marshal), required from omitempty |
| 160 | // analysis, additionalProperties:false, plus tag-derived minLength and |
| 161 | // minimum/maximum constraints. |
| 162 | func buildObjectSchema(defs map[string]any, typ reflect.Type) (map[string]any, error) { |
| 163 | properties := map[string]any{} |
| 164 | var required []string |
| 165 | for i := 0; i < typ.NumField(); i++ { |
| 166 | field := typ.Field(i) |
| 167 | if field.PkgPath != "" { |
| 168 | continue |
| 169 | } |
| 170 | name, omitEmpty, skip := jsonField(field) |
| 171 | if skip { |
| 172 | continue |
| 173 | } |
| 174 | if field.Anonymous && name == "" { |
| 175 | return nil, fmt.Errorf("embedded field %v is not supported in wire DTOs", field.Type) |
| 176 | } |
| 177 | schema, err := buildJSONSchema(defs, field.Type) |
| 178 | if err != nil { |
| 179 | return nil, fmt.Errorf("field %s: %w", name, err) |
| 180 | } |
| 181 | schema = applyFieldTags(schema, field) |
| 182 | properties[name] = schema |
| 183 | if !omitEmpty { |
| 184 | required = append(required, name) |
| 185 | } |
| 186 | } |
| 187 | for i := 1; i < len(required); i++ { |
| 188 | if required[i-1] == required[i] { |
| 189 | return nil, fmt.Errorf("duplicate JSON field %q", required[i]) |
| 190 | } |
| 191 | } |
| 192 | sort.Strings(required) |
| 193 | object := map[string]any{ |
| 194 | "type": "object", |
| 195 | "additionalProperties": false, |
| 196 | "properties": properties, |
| 197 | } |
| 198 | if len(required) > 0 { |
| 199 | object["required"] = required |
| 200 | } |
| 201 | return object, nil |
| 202 | } |
| 203 | |
| 204 | // applyFieldTags folds the validate/externalizable struct tags into JSON |
| 205 | // Schema constraints: nonempty → minLength, min=/max= → minimum/maximum, |
| 206 | // externalizable → the x-externalizable annotation. |
| 207 | func applyFieldTags(schema any, field reflect.StructField) any { |
| 208 | object, ok := schema.(map[string]any) |
| 209 | if !ok { |
| 210 | // Unconstrained JSON (json.RawMessage, any) is the boolean schema |
| 211 | // true; an externalizable tag still needs its annotation, so upgrade |
| 212 | // to an annotation-only object schema, which accepts the same values. |
| 213 | if externalizable(field) { |
| 214 | return map[string]any{"x-externalizable": true} |
| 215 | } |
| 216 | return schema |
| 217 | } |
| 218 | for _, tag := range strings.Split(field.Tag.Get("validate"), ",") { |
| 219 | switch { |
| 220 | case tag == "nonempty": |
| 221 | if object["type"] == "string" { |
| 222 | object["minLength"] = 1 |
| 223 | } |
| 224 | case strings.HasPrefix(tag, "min="): |
| 225 | if minimum, err := strconv.ParseFloat(strings.TrimPrefix(tag, "min="), 64); err == nil { |
| 226 | object["minimum"] = minimum |
| 227 | } |
| 228 | case strings.HasPrefix(tag, "max="): |
| 229 | if maximum, err := strconv.ParseFloat(strings.TrimPrefix(tag, "max="), 64); err == nil { |
| 230 | object["maximum"] = maximum |
| 231 | } |
| 232 | } |
| 233 | } |
| 234 | if externalizable(field) { |
| 235 | object["x-externalizable"] = true |
| 236 | } |
| 237 | return object |
| 238 | } |
| 239 | |
| 240 | func externalizable(field reflect.StructField) bool { |
| 241 | return field.Tag.Get("externalizable") == "true" |
| 242 | } |
| 243 | |
| 244 | var ( |
| 245 | schemaOnce sync.Once |
| 246 | schemaBytes []byte |
| 247 | schemaErr error |
| 248 | ) |
| 249 | |
| 250 | // CanonicalSchemaBytes is the deterministic byte form of BuildSchemaDocument: |
| 251 | // one compact JSON document, identical across runs and processes. |
| 252 | func CanonicalSchemaBytes() ([]byte, error) { |
| 253 | schemaOnce.Do(func() { |
| 254 | document, err := BuildSchemaDocument() |
| 255 | if err != nil { |
| 256 | schemaErr = err |
| 257 | return |
| 258 | } |
| 259 | schemaBytes, schemaErr = json.Marshal(document) |
| 260 | }) |
| 261 | if schemaErr != nil { |
| 262 | return nil, schemaErr |
| 263 | } |
| 264 | return append([]byte(nil), schemaBytes...), nil |
| 265 | } |
| 266 | |
| 267 | // SchemaHash returns the committed SHA-256 of the canonical schema document. |
| 268 | // Handshake comparisons use it to prove both peers run the identical frozen |
| 269 | // contract. |
| 270 | func SchemaHash() string { |
| 271 | return GeneratedSchemaHash |
| 272 | } |
| 273 |