github.com/anthropics/anthropic-sdk-go@v1.70.1/schemautil_test.go (about) 1 package anthropic 2 3 import ( 4 "encoding/json" 5 "strings" 6 "testing" 7 8 "github.com/invopop/jsonschema" 9 orderedmap "github.com/pb33f/ordered-map/v2" 10 ) 11 12 func ptr[T any](v T) *T { return &v } 13 14 func props(pairs ...any) *orderedmap.OrderedMap[string, *jsonschema.Schema] { 15 m := orderedmap.New[string, *jsonschema.Schema]() 16 for i := 0; i < len(pairs); i += 2 { 17 m.Set(pairs[i].(string), pairs[i+1].(*jsonschema.Schema)) 18 } 19 return m 20 } 21 22 // normalizeJSON round-trips JSON through any to normalize key ordering, 23 // since json.Marshal sorts map keys deterministically. 24 func normalizeJSON(s string) string { 25 var v any 26 if err := json.Unmarshal([]byte(s), &v); err != nil { 27 return s 28 } 29 b, _ := json.Marshal(v) 30 return string(b) 31 } 32 33 func TestTransformSchema(t *testing.T) { 34 tests := []struct { 35 name string 36 input *jsonschema.Schema 37 expected string // expected JSON after transform 38 }{ 39 { 40 name: "nil schema is a no-op", 41 input: nil, 42 expected: "", 43 }, 44 { 45 name: "integer with unsupported constraints moves them to description", 46 input: &jsonschema.Schema{ 47 Type: "integer", 48 Minimum: "1", 49 Maximum: "10", 50 Description: "A number", 51 }, 52 expected: `{"type":"integer","description":"A number\n\n{maximum: 10, minimum: 1}"}`, 53 }, 54 { 55 name: "object gets additionalProperties false and recurses into properties", 56 input: &jsonschema.Schema{ 57 Type: "object", 58 Properties: props( 59 "name", &jsonschema.Schema{Type: "string"}, 60 "age", &jsonschema.Schema{Type: "integer"}, 61 ), 62 Required: []string{"name"}, 63 }, 64 expected: `{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"additionalProperties":false,"required":["name"]}`, 65 }, 66 { 67 name: "object without properties gets empty properties", 68 input: &jsonschema.Schema{ 69 Type: "object", 70 }, 71 expected: `{"type":"object","properties":{},"additionalProperties":false}`, 72 }, 73 { 74 name: "supported string format is preserved", 75 input: &jsonschema.Schema{ 76 Type: "string", 77 Format: "date-time", 78 }, 79 expected: `{"type":"string","format":"date-time"}`, 80 }, 81 { 82 name: "unsupported string format moves to description", 83 input: &jsonschema.Schema{ 84 Type: "string", 85 Format: "binary", 86 }, 87 expected: `{"type":"string","description":"{format: binary}"}`, 88 }, 89 { 90 name: "array with minItems 1 is preserved", 91 input: &jsonschema.Schema{ 92 Type: "array", 93 Items: &jsonschema.Schema{Type: "string"}, 94 MinItems: ptr(uint64(1)), 95 }, 96 expected: `{"type":"array","items":{"type":"string"},"minItems":1}`, 97 }, 98 { 99 name: "array with minItems 5 moves to description", 100 input: &jsonschema.Schema{ 101 Type: "array", 102 Items: &jsonschema.Schema{Type: "string"}, 103 MinItems: ptr(uint64(5)), 104 }, 105 expected: `{"type":"array","items":{"type":"string"},"description":"{minItems: 5}"}`, 106 }, 107 { 108 name: "array recurses into items", 109 input: &jsonschema.Schema{ 110 Type: "array", 111 Items: &jsonschema.Schema{ 112 Type: "object", 113 Properties: props( 114 "id", &jsonschema.Schema{Type: "integer"}, 115 ), 116 }, 117 }, 118 expected: `{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"}},"additionalProperties":false}}`, 119 }, 120 { 121 name: "$ref strips all other properties", 122 input: &jsonschema.Schema{ 123 Ref: "#/definitions/Person", 124 Type: "object", 125 }, 126 expected: `{"$ref":"#/definitions/Person"}`, 127 }, 128 { 129 name: "oneOf converted to anyOf", 130 input: &jsonschema.Schema{ 131 OneOf: []*jsonschema.Schema{ 132 {Type: "string"}, 133 {Type: "number"}, 134 }, 135 }, 136 expected: `{"anyOf":[{"type":"string"},{"type":"number"}]}`, 137 }, 138 { 139 name: "anyOf variants are recursively transformed", 140 input: &jsonschema.Schema{ 141 AnyOf: []*jsonschema.Schema{ 142 {Type: "string"}, 143 {Type: "object", Properties: props("x", &jsonschema.Schema{Type: "integer"})}, 144 }, 145 }, 146 expected: `{"anyOf":[{"type":"string"},{"type":"object","properties":{"x":{"type":"integer"}},"additionalProperties":false}]}`, 147 }, 148 { 149 name: "$defs are recursively transformed", 150 input: &jsonschema.Schema{ 151 Type: "object", 152 Definitions: jsonschema.Definitions{ 153 "Person": &jsonschema.Schema{ 154 Type: "object", 155 Properties: props( 156 "name", &jsonschema.Schema{Type: "string"}, 157 ), 158 }, 159 }, 160 Properties: props( 161 "user", &jsonschema.Schema{Ref: "#/$defs/Person"}, 162 ), 163 }, 164 expected: `{"type":"object","$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"additionalProperties":false}},"properties":{"user":{"$ref":"#/$defs/Person"}},"additionalProperties":false}`, 165 }, 166 { 167 name: "no type and no anyOf clears the schema", 168 input: &jsonschema.Schema{ 169 Description: "orphan", 170 }, 171 // Annotation-only schemas are zeroed; a zero jsonschema.Schema 172 // marshals as `true` (the "match everything" schema). 173 expected: `true`, 174 }, 175 { 176 name: "$schema version is stripped", 177 input: &jsonschema.Schema{ 178 Version: "https://json-schema.org/draft/2020-12/schema", 179 Type: "object", 180 Properties: props( 181 "name", &jsonschema.Schema{Type: "string"}, 182 ), 183 }, 184 expected: `{"type":"object","properties":{"name":{"type":"string"}},"additionalProperties":false,"description":"{$schema: https://json-schema.org/draft/2020-12/schema}"}`, 185 }, 186 { 187 name: "invalid anyOf variants are dropped, not serialized as true", 188 input: &jsonschema.Schema{ 189 AnyOf: []*jsonschema.Schema{ 190 {Type: "string"}, 191 {Description: "orphan: no type, no anyOf"}, 192 }, 193 }, 194 expected: `{"anyOf":[{"type":"string"}]}`, 195 }, 196 { 197 name: "invalid oneOf variants are dropped during conversion to anyOf", 198 input: &jsonschema.Schema{ 199 OneOf: []*jsonschema.Schema{ 200 {Type: "number"}, 201 {Description: "orphan"}, 202 }, 203 }, 204 expected: `{"anyOf":[{"type":"number"}]}`, 205 }, 206 { 207 name: "unsupported not field renders as JSON in description", 208 input: &jsonschema.Schema{ 209 Type: "string", 210 Not: &jsonschema.Schema{Type: "number"}, 211 }, 212 expected: `{"type":"string","description":"{not: {\"type\":\"number\"}}"}`, 213 }, 214 { 215 name: "enum is preserved", 216 input: &jsonschema.Schema{ 217 Type: "string", 218 Enum: []any{"red", "green", "blue"}, 219 }, 220 expected: `{"type":"string","enum":["red","green","blue"]}`, 221 }, 222 { 223 name: "enum without type is preserved", 224 input: &jsonschema.Schema{ 225 Enum: []any{"a", "b"}, 226 }, 227 expected: `{"enum":["a","b"]}`, 228 }, 229 { 230 name: "const is preserved", 231 input: &jsonschema.Schema{ 232 Type: "string", 233 Const: "fixed", 234 }, 235 expected: `{"type":"string","const":"fixed"}`, 236 }, 237 { 238 name: "const without type is preserved", 239 input: &jsonschema.Schema{ 240 Const: 42, 241 }, 242 expected: `{"const":42}`, 243 }, 244 { 245 name: "string pattern is preserved", 246 input: &jsonschema.Schema{ 247 Type: "string", 248 Pattern: "^[a-z]+$", 249 }, 250 expected: `{"type":"string","pattern":"^[a-z]+$"}`, 251 }, 252 { 253 name: "allOf variants are preserved and recursively transformed", 254 input: &jsonschema.Schema{ 255 AllOf: []*jsonschema.Schema{ 256 {Type: "object", Properties: props("a", &jsonschema.Schema{Type: "string"})}, 257 {Type: "object", Properties: props("b", &jsonschema.Schema{Type: "integer"}), Required: []string{"b"}}, 258 }, 259 }, 260 expected: `{"allOf":[{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":false},{"type":"object","properties":{"b":{"type":"integer"}},"additionalProperties":false,"required":["b"]}]}`, 261 }, 262 } 263 264 for _, tt := range tests { 265 t.Run(tt.name, func(t *testing.T) { 266 if tt.input == nil { 267 // Just verify no panic 268 transformSchema(nil) 269 return 270 } 271 transformSchema(tt.input) 272 gotBytes, _ := json.Marshal(tt.input) 273 got := normalizeJSON(string(gotBytes)) 274 want := normalizeJSON(tt.expected) 275 if got != want { 276 // Pretty print for readable diffs 277 var gotAny, wantAny any 278 json.Unmarshal([]byte(got), &gotAny) 279 json.Unmarshal([]byte(tt.expected), &wantAny) 280 gotPretty, _ := json.MarshalIndent(gotAny, "", " ") 281 wantPretty, _ := json.MarshalIndent(wantAny, "", " ") 282 t.Errorf("transformSchema() mismatch:\ngot:\n%s\nwant:\n%s", gotPretty, wantPretty) 283 } 284 }) 285 } 286 } 287 288 func TestTransformSchemaFromReflector(t *testing.T) { 289 type OrderItem struct { 290 Name string `json:"name"` 291 Quantity int `json:"quantity" jsonschema:"minimum=1"` 292 Price float64 `json:"price"` 293 } 294 type Order struct { 295 Items []OrderItem `json:"items" jsonschema:"description=List of items"` 296 Total float64 `json:"total"` 297 Currency string `json:"currency" jsonschema:"enum=USD,enum=EUR"` 298 } 299 300 reflector := jsonschema.Reflector{DoNotReference: true} 301 schema := reflector.Reflect(&Order{}) 302 transformSchema(schema) 303 304 result, err := json.Marshal(schema) 305 if err != nil { 306 t.Fatalf("failed to marshal: %v", err) 307 } 308 309 var m map[string]any 310 if err := json.Unmarshal(result, &m); err != nil { 311 t.Fatalf("failed to unmarshal: %v", err) 312 } 313 314 // Top-level object must have additionalProperties: false 315 if ap, ok := m["additionalProperties"]; !ok || ap != false { 316 t.Errorf("expected top-level additionalProperties=false, got %v", m["additionalProperties"]) 317 } 318 319 // $schema should be stripped (moved to description) 320 if _, ok := m["$schema"]; ok { 321 t.Error("expected $schema to be stripped from output") 322 } 323 324 // Properties should exist 325 props, ok := m["properties"].(map[string]any) 326 if !ok { 327 t.Fatal("expected properties map") 328 } 329 for _, key := range []string{"items", "total", "currency"} { 330 if _, ok := props[key]; !ok { 331 t.Errorf("expected property %q", key) 332 } 333 } 334 335 // Nested items schema should also have additionalProperties: false 336 itemsProp, ok := props["items"].(map[string]any) 337 if !ok { 338 t.Fatal("expected items property to be a map") 339 } 340 itemsSchema, ok := itemsProp["items"].(map[string]any) 341 if !ok { 342 t.Fatal("expected items.items (array element schema) to be a map") 343 } 344 if ap, ok := itemsSchema["additionalProperties"]; !ok || ap != false { 345 t.Errorf("expected nested additionalProperties=false, got %v", itemsSchema["additionalProperties"]) 346 } 347 348 // Unsupported constraint (minimum=1 on quantity) should be in description 349 quantitySchema, ok := itemsSchema["properties"].(map[string]any)["quantity"].(map[string]any) 350 if !ok { 351 t.Fatal("expected quantity property") 352 } 353 desc, _ := quantitySchema["description"].(string) 354 if desc == "" { 355 t.Error("expected quantity to have a description with the minimum constraint") 356 } 357 } 358 359 // Keywords like maxLength and maxItems are pointer-typed on jsonschema.Schema, so 360 // the description must render the pointed-to value rather than the address. 361 func TestTransformSchemaFromReflectorPointerKeywords(t *testing.T) { 362 type Input struct { 363 Code string `json:"code" jsonschema:"minLength=2,maxLength=8"` 364 Tags []string `json:"tags" jsonschema:"maxItems=3"` 365 } 366 367 reflector := jsonschema.Reflector{DoNotReference: true} 368 schema := reflector.Reflect(&Input{}) 369 transformSchema(schema) 370 371 tests := []struct { 372 property string 373 want string 374 }{ 375 {"code", "{maxLength: 8, minLength: 2}"}, 376 {"tags", "{maxItems: 3}"}, 377 } 378 for _, tt := range tests { 379 t.Run(tt.property+" keywords render as values in the description", func(t *testing.T) { 380 prop, ok := schema.Properties.Get(tt.property) 381 if !ok { 382 t.Fatalf("expected property %q", tt.property) 383 } 384 if strings.Contains(prop.Description, "0x") { 385 t.Errorf("description contains a pointer address: %q", prop.Description) 386 } 387 if prop.Description != tt.want { 388 t.Errorf("description = %q, want %q", prop.Description, tt.want) 389 } 390 }) 391 } 392 } 393 394 func TestFormatExtraValue(t *testing.T) { 395 tests := []struct { 396 name string 397 v any 398 want string 399 }{ 400 {"pointer to integer renders the value", ptr(uint64(8)), "8"}, 401 {"nil pointer renders as null", (*uint64)(nil), "null"}, 402 {"untyped nil renders as null", nil, "null"}, 403 {"plain integer", uint64(3), "3"}, 404 {"string stays unquoted", "date", "date"}, 405 {"pointer to schema is JSON-marshaled", &jsonschema.Schema{Type: "string"}, `{"type":"string"}`}, 406 } 407 for _, tt := range tests { 408 t.Run(tt.name, func(t *testing.T) { 409 if got := formatExtraValue(tt.v); got != tt.want { 410 t.Errorf("formatExtraValue(%#v) = %q, want %q", tt.v, got, tt.want) 411 } 412 }) 413 } 414 } 415 416 func TestTransformSchemaMap(t *testing.T) { 417 tests := []struct { 418 name string 419 input map[string]any 420 expected map[string]any 421 }{ 422 { 423 name: "basic integer with unsupported properties", 424 input: map[string]any{ 425 "type": "integer", 426 "minimum": 1, 427 "maximum": 10, 428 "description": "A number", 429 }, 430 expected: map[string]any{ 431 "type": "integer", 432 "description": "A number\n\n{maximum: 10, minimum: 1}", 433 }, 434 }, 435 { 436 name: "object with properties", 437 input: map[string]any{ 438 "type": "object", 439 "properties": map[string]any{ 440 "name": map[string]any{ 441 "type": "string", 442 }, 443 "age": map[string]any{ 444 "type": "integer", 445 }, 446 }, 447 "required": []string{"name"}, 448 "additionalProperties": true, 449 }, 450 expected: map[string]any{ 451 "type": "object", 452 "properties": map[string]any{ 453 "name": map[string]any{ 454 "type": "string", 455 }, 456 "age": map[string]any{ 457 "type": "integer", 458 }, 459 }, 460 "required": []string{"name"}, 461 "additionalProperties": false, 462 }, 463 }, 464 { 465 name: "string with supported format", 466 input: map[string]any{ 467 "type": "string", 468 "format": "date-time", 469 }, 470 expected: map[string]any{ 471 "type": "string", 472 "format": "date-time", 473 }, 474 }, 475 { 476 name: "string with unsupported format", 477 input: map[string]any{ 478 "type": "string", 479 "format": "binary", 480 }, 481 expected: map[string]any{ 482 "type": "string", 483 "description": "{format: binary}", 484 }, 485 }, 486 { 487 name: "array with minItems 1", 488 input: map[string]any{ 489 "type": "array", 490 "items": map[string]any{ 491 "type": "string", 492 }, 493 "minItems": 1, 494 }, 495 expected: map[string]any{ 496 "type": "array", 497 "items": map[string]any{ 498 "type": "string", 499 }, 500 "minItems": 1, 501 }, 502 }, 503 { 504 name: "array with minItems 5", 505 input: map[string]any{ 506 "type": "array", 507 "items": map[string]any{ 508 "type": "string", 509 }, 510 "minItems": 5, 511 }, 512 expected: map[string]any{ 513 "type": "array", 514 "items": map[string]any{ 515 "type": "string", 516 }, 517 "description": "{minItems: 5}", 518 }, 519 }, 520 { 521 name: "schema with $ref", 522 input: map[string]any{ 523 "$ref": "#/definitions/Person", 524 "type": "object", 525 }, 526 expected: map[string]any{ 527 "$ref": "#/definitions/Person", 528 }, 529 }, 530 { 531 name: "schema with anyOf", 532 input: map[string]any{ 533 "anyOf": []any{ 534 map[string]any{ 535 "type": "string", 536 }, 537 map[string]any{ 538 "type": "number", 539 }, 540 }, 541 }, 542 expected: map[string]any{ 543 "anyOf": []any{ 544 map[string]any{ 545 "type": "string", 546 }, 547 map[string]any{ 548 "type": "number", 549 }, 550 }, 551 }, 552 }, 553 { 554 name: "schema with oneOf converted to anyOf", 555 input: map[string]any{ 556 "oneOf": []any{ 557 map[string]any{ 558 "type": "string", 559 }, 560 map[string]any{ 561 "type": "number", 562 }, 563 }, 564 }, 565 expected: map[string]any{ 566 "anyOf": []any{ 567 map[string]any{ 568 "type": "string", 569 }, 570 map[string]any{ 571 "type": "number", 572 }, 573 }, 574 }, 575 }, 576 { 577 name: "schema with $defs", 578 input: map[string]any{ 579 "type": "object", 580 "$defs": map[string]any{ 581 "Person": map[string]any{ 582 "type": "object", 583 "properties": map[string]any{ 584 "name": map[string]any{ 585 "type": "string", 586 }, 587 }, 588 }, 589 }, 590 "properties": map[string]any{ 591 "user": map[string]any{ 592 "$ref": "#/$defs/Person", 593 }, 594 }, 595 }, 596 expected: map[string]any{ 597 "type": "object", 598 "$defs": map[string]any{ 599 "Person": map[string]any{ 600 "type": "object", 601 "properties": map[string]any{ 602 "name": map[string]any{ 603 "type": "string", 604 }, 605 }, 606 "additionalProperties": false, 607 }, 608 }, 609 "properties": map[string]any{ 610 "user": map[string]any{ 611 "$ref": "#/$defs/Person", 612 }, 613 }, 614 "additionalProperties": false, 615 }, 616 }, 617 { 618 name: "nil schema returns nil", 619 input: nil, 620 expected: nil, 621 }, 622 { 623 name: "schema without type returns nil", 624 input: map[string]any{ 625 "description": "A schema without type", 626 }, 627 expected: nil, 628 }, 629 { 630 name: "array items false remains false", 631 input: map[string]any{ 632 "type": "array", 633 "items": false, 634 }, 635 expected: map[string]any{ 636 "type": "array", 637 "items": false, 638 }, 639 }, 640 { 641 name: "object property false remains false", 642 input: map[string]any{ 643 "type": "object", 644 "properties": map[string]any{ 645 "x": false, 646 }, 647 }, 648 expected: map[string]any{ 649 "type": "object", 650 "properties": map[string]any{ 651 "x": false, 652 }, 653 "additionalProperties": false, 654 }, 655 }, 656 } 657 658 for _, tt := range tests { 659 t.Run(tt.name, func(t *testing.T) { 660 result := transformSchemaMap(tt.input) 661 // Compare via JSON to avoid Go type mismatches (e.g. []string vs []any, 662 // int vs float64) that arise from the JSON round-trip in transformSchemaMap. 663 resultJSON, _ := json.Marshal(result) 664 expectedJSON, _ := json.Marshal(tt.expected) 665 if string(resultJSON) != string(expectedJSON) { 666 resultPretty, _ := json.MarshalIndent(result, "", " ") 667 expectedPretty, _ := json.MarshalIndent(tt.expected, "", " ") 668 t.Errorf("transformSchemaMap() mismatch:\ngot:\n%s\nwant:\n%s", resultPretty, expectedPretty) 669 } 670 }) 671 } 672 } 673 674 func TestStructuredSchemaHelpersPreserveFalseSubschemas(t *testing.T) { 675 tests := []struct { 676 name string 677 got any 678 expected string 679 }{ 680 { 681 name: "BetaJSONSchemaOutputFormat preserves false child schemas", 682 got: BetaJSONSchemaOutputFormat(map[string]any{"type": "array", "items": false}).Schema, 683 expected: `{"type":"array","items":false}`, 684 }, 685 { 686 name: "BetaToolInputSchema preserves false child schemas", 687 got: BetaToolInputSchema(map[string]any{"type": "object", "properties": map[string]any{"x": false}}).ExtraFields, 688 expected: `{"type":"object","properties":{"x":false},"additionalProperties":false}`, 689 }, 690 } 691 692 for _, tt := range tests { 693 t.Run(tt.name, func(t *testing.T) { 694 got, _ := json.Marshal(tt.got) 695 if normalizeJSON(string(got)) != normalizeJSON(tt.expected) { 696 t.Errorf("schema helper mismatch:\ngot:\n%s\nwant:\n%s", got, tt.expected) 697 } 698 }) 699 } 700 }