Jeremiah Lowin commited on
Commit
93a4c8b
·
unverified ·
2 Parent(s): d7899a9df0a1cf

Merge pull request #1035 from jlowin/add-openapi-compatibility-tests

Browse files
tests/server/openapi/test_openapi_compatibility.py CHANGED
@@ -7,6 +7,7 @@ from pydantic.networks import AnyUrl
7
  from fastmcp import FastMCP
8
  from fastmcp.client import Client
9
  from fastmcp.server.openapi import FastMCPOpenAPI
 
10
 
11
  from .conftest import GET_ROUTE_MAPS
12
 
@@ -80,8 +81,6 @@ class TestOpenAPI30Compatibility:
80
  ],
81
  )
82
  elif request.url.path == "/products" and request.method == "POST":
83
- import json
84
-
85
  data = json.loads(request.content)
86
  return httpx.Response(
87
  201, json={"id": "p3", "name": data["name"], "price": data["price"]}
@@ -257,8 +256,6 @@ class TestOpenAPI31Compatibility:
257
  ],
258
  )
259
  elif request.url.path == "/orders" and request.method == "POST":
260
- import json
261
-
262
  data = json.loads(request.content)
263
  return httpx.Response(
264
  201,
@@ -369,3 +366,296 @@ class TestOpenAPI31Compatibility:
369
  assert result.data["id"] == "o3"
370
  assert result.data["customer"] == "Charlie"
371
  assert result.data["items"] == ["item4", "item5"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from fastmcp import FastMCP
8
  from fastmcp.client import Client
9
  from fastmcp.server.openapi import FastMCPOpenAPI
10
+ from fastmcp.utilities.openapi import parse_openapi_to_http_routes
11
 
12
  from .conftest import GET_ROUTE_MAPS
13
 
 
81
  ],
82
  )
83
  elif request.url.path == "/products" and request.method == "POST":
 
 
84
  data = json.loads(request.content)
85
  return httpx.Response(
86
  201, json={"id": "p3", "name": data["name"], "price": data["price"]}
 
256
  ],
257
  )
258
  elif request.url.path == "/orders" and request.method == "POST":
 
 
259
  data = json.loads(request.content)
260
  return httpx.Response(
261
  201,
 
366
  assert result.data["id"] == "o3"
367
  assert result.data["customer"] == "Charlie"
368
  assert result.data["items"] == ["item4", "item5"]
369
+
370
+
371
+ class TestOpenAPIVersionDifferences:
372
+ """Test specific differences between OpenAPI 3.0 and 3.1 that can cause compatibility issues."""
373
+
374
+ def test_openapi_30_exclusive_maximum_boolean_format(self):
375
+ """Test OpenAPI 3.0 format with boolean exclusiveMaximum (reproduces GitHub issue #1021)."""
376
+ spec_with_exclusive_max = {
377
+ "openapi": "3.0.0",
378
+ "info": {"title": "Loan API", "version": "1.0.0"},
379
+ "paths": {
380
+ "/loans": {
381
+ "post": {
382
+ "operationId": "createLoan",
383
+ "summary": "Create a loan",
384
+ "requestBody": {
385
+ "required": True,
386
+ "content": {
387
+ "application/json": {
388
+ "schema": {
389
+ "$ref": "#/components/schemas/LoanDetails"
390
+ }
391
+ }
392
+ },
393
+ },
394
+ "responses": {"201": {"description": "Loan created"}},
395
+ }
396
+ }
397
+ },
398
+ "components": {
399
+ "schemas": {
400
+ "LoanDetails": {
401
+ "type": "object",
402
+ "properties": {
403
+ "amount": {"type": "number", "minimum": 0},
404
+ "interest_rate": {
405
+ "type": "number",
406
+ "minimum": 0,
407
+ "maximum": 100,
408
+ "exclusiveMaximum": True, # OpenAPI 3.0 boolean format
409
+ },
410
+ },
411
+ "required": ["amount", "interest_rate"],
412
+ }
413
+ }
414
+ },
415
+ }
416
+
417
+ # This should not raise a ValidationError
418
+ routes = parse_openapi_to_http_routes(spec_with_exclusive_max)
419
+ assert len(routes) == 1
420
+ assert routes[0].operation_id == "createLoan"
421
+
422
+ def test_openapi_31_exclusive_maximum_numeric_format(self):
423
+ """Test OpenAPI 3.1 format with numeric exclusiveMaximum."""
424
+ spec_with_exclusive_max = {
425
+ "openapi": "3.1.0",
426
+ "info": {"title": "Loan API", "version": "1.0.0"},
427
+ "paths": {
428
+ "/loans": {
429
+ "post": {
430
+ "operationId": "createLoan",
431
+ "summary": "Create a loan",
432
+ "requestBody": {
433
+ "required": True,
434
+ "content": {
435
+ "application/json": {
436
+ "schema": {
437
+ "$ref": "#/components/schemas/LoanDetails"
438
+ }
439
+ }
440
+ },
441
+ },
442
+ "responses": {"201": {"description": "Loan created"}},
443
+ }
444
+ }
445
+ },
446
+ "components": {
447
+ "schemas": {
448
+ "LoanDetails": {
449
+ "type": "object",
450
+ "properties": {
451
+ "amount": {"type": "number", "minimum": 0},
452
+ "interest_rate": {
453
+ "type": "number",
454
+ "minimum": 0,
455
+ "exclusiveMaximum": 100, # OpenAPI 3.1 numeric format
456
+ },
457
+ },
458
+ "required": ["amount", "interest_rate"],
459
+ }
460
+ }
461
+ },
462
+ }
463
+
464
+ # This should not raise a ValidationError
465
+ routes = parse_openapi_to_http_routes(spec_with_exclusive_max)
466
+ assert len(routes) == 1
467
+ assert routes[0].operation_id == "createLoan"
468
+
469
+ def test_openapi_30_nullable_format(self):
470
+ """Test OpenAPI 3.0 nullable format."""
471
+ spec_with_nullable = {
472
+ "openapi": "3.0.0",
473
+ "info": {"title": "User API", "version": "1.0.0"},
474
+ "paths": {
475
+ "/users": {
476
+ "post": {
477
+ "operationId": "createUser",
478
+ "summary": "Create a user",
479
+ "requestBody": {
480
+ "required": True,
481
+ "content": {
482
+ "application/json": {
483
+ "schema": {
484
+ "type": "object",
485
+ "properties": {
486
+ "name": {"type": "string"},
487
+ "email": {
488
+ "type": "string",
489
+ "nullable": True, # OpenAPI 3.0 nullable format
490
+ },
491
+ },
492
+ "required": ["name"],
493
+ }
494
+ }
495
+ },
496
+ },
497
+ "responses": {"201": {"description": "User created"}},
498
+ }
499
+ }
500
+ },
501
+ }
502
+
503
+ # This should not raise a ValidationError
504
+ routes = parse_openapi_to_http_routes(spec_with_nullable)
505
+ assert len(routes) == 1
506
+ assert routes[0].operation_id == "createUser"
507
+
508
+ def test_openapi_31_type_array_format(self):
509
+ """Test OpenAPI 3.1 type array format for nullable values."""
510
+ spec_with_type_array = {
511
+ "openapi": "3.1.0",
512
+ "info": {"title": "User API", "version": "1.0.0"},
513
+ "paths": {
514
+ "/users": {
515
+ "post": {
516
+ "operationId": "createUser",
517
+ "summary": "Create a user",
518
+ "requestBody": {
519
+ "required": True,
520
+ "content": {
521
+ "application/json": {
522
+ "schema": {
523
+ "type": "object",
524
+ "properties": {
525
+ "name": {"type": "string"},
526
+ "email": {
527
+ "type": [
528
+ "string",
529
+ "null",
530
+ ], # OpenAPI 3.1 type array format
531
+ },
532
+ },
533
+ "required": ["name"],
534
+ }
535
+ }
536
+ },
537
+ },
538
+ "responses": {"201": {"description": "User created"}},
539
+ }
540
+ }
541
+ },
542
+ }
543
+
544
+ # This should not raise a ValidationError
545
+ routes = parse_openapi_to_http_routes(spec_with_type_array)
546
+ assert len(routes) == 1
547
+ assert routes[0].operation_id == "createUser"
548
+
549
+ def test_openapi_30_with_defs_and_exclusive_maximum(self):
550
+ """Test OpenAPI 3.0 with $defs and exclusiveMaximum (complex case from GitHub issue #1021)."""
551
+ spec_with_defs = {
552
+ "openapi": "3.0.0",
553
+ "info": {"title": "Complex Loan API", "version": "1.0.0"},
554
+ "paths": {
555
+ "/loans": {
556
+ "post": {
557
+ "operationId": "createComplexLoan",
558
+ "summary": "Create a complex loan",
559
+ "requestBody": {
560
+ "required": True,
561
+ "content": {
562
+ "application/json": {
563
+ "schema": {
564
+ "type": "object",
565
+ "properties": {
566
+ "loanDetails": {
567
+ "$ref": "#/components/schemas/LoanDetails"
568
+ }
569
+ },
570
+ "required": ["loanDetails"],
571
+ "$defs": {
572
+ "LoanDetails": {
573
+ "type": "object",
574
+ "properties": {
575
+ "interest_rate": {
576
+ "type": "number",
577
+ "minimum": 0,
578
+ "maximum": 100,
579
+ "exclusiveMaximum": True, # This should trigger the issue
580
+ },
581
+ },
582
+ "required": ["interest_rate"],
583
+ }
584
+ },
585
+ }
586
+ }
587
+ },
588
+ },
589
+ "responses": {"201": {"description": "Complex loan created"}},
590
+ }
591
+ }
592
+ },
593
+ "components": {
594
+ "schemas": {
595
+ "LoanDetails": {
596
+ "type": "object",
597
+ "properties": {
598
+ "interest_rate": {
599
+ "type": "number",
600
+ "minimum": 0,
601
+ "maximum": 100,
602
+ "exclusiveMaximum": True,
603
+ },
604
+ },
605
+ "required": ["interest_rate"],
606
+ }
607
+ }
608
+ },
609
+ }
610
+
611
+ # This should not raise a ValidationError (GitHub issue #1021 should be fixed)
612
+ routes = parse_openapi_to_http_routes(spec_with_defs)
613
+ assert len(routes) == 1
614
+ assert routes[0].operation_id == "createComplexLoan"
615
+
616
+ def test_openapi_30_edge_case_with_multiple_exclusive_constraints(self):
617
+ """Test edge case with multiple exclusive constraints that might trigger validation issues."""
618
+ spec_edge_case = {
619
+ "openapi": "3.0.0",
620
+ "info": {"title": "Edge Case API", "version": "1.0.0"},
621
+ "paths": {
622
+ "/validate": {
623
+ "post": {
624
+ "operationId": "validateData",
625
+ "summary": "Validate data with edge case constraints",
626
+ "requestBody": {
627
+ "required": True,
628
+ "content": {
629
+ "application/json": {
630
+ "schema": {
631
+ "type": "object",
632
+ "properties": {
633
+ "percentage": {
634
+ "type": "number",
635
+ "minimum": 0,
636
+ "maximum": 100,
637
+ "exclusiveMaximum": True,
638
+ "exclusiveMinimum": True, # Both exclusive constraints
639
+ },
640
+ "rating": {
641
+ "type": "integer",
642
+ "minimum": 1,
643
+ "maximum": 10,
644
+ "exclusiveMaximum": True,
645
+ },
646
+ },
647
+ "required": ["percentage", "rating"],
648
+ }
649
+ }
650
+ },
651
+ },
652
+ "responses": {"200": {"description": "Data validated"}},
653
+ }
654
+ }
655
+ },
656
+ }
657
+
658
+ # This might trigger validation issues with multiple exclusive constraints
659
+ routes = parse_openapi_to_http_routes(spec_edge_case)
660
+ assert len(routes) == 1
661
+ assert routes[0].operation_id == "validateData"