Spaces:
Running
Running
| """Tests for the OpenAPI parsing utilities.""" | |
| from typing import Any | |
| import pytest | |
| from fastapi import Body, FastAPI, Path, Query | |
| from pydantic import BaseModel, Field | |
| from fastmcp.utilities.openapi import ( | |
| _combine_schemas, | |
| _replace_ref_with_defs, | |
| parse_openapi_to_http_routes, | |
| ) | |
| # --- Test Data: Static OpenAPI Schema Dictionaries --- # | |
| def petstore_schema() -> dict[str, Any]: | |
| """Fixture that returns a simple Pet Store API schema.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "Simple Pet Store API", "version": "1.0.0"}, | |
| "paths": { | |
| "/pets": { | |
| "get": { | |
| "summary": "List all pets", | |
| "operationId": "listPets", | |
| "tags": ["pets"], | |
| "parameters": [ | |
| { | |
| "name": "limit", | |
| "in": "query", | |
| "description": "How many items to return", | |
| "required": False, | |
| "schema": {"type": "integer", "format": "int32"}, | |
| } | |
| ], | |
| "responses": {"200": {"description": "A paged array of pets"}}, | |
| }, | |
| "post": { | |
| "summary": "Create a pet", | |
| "operationId": "createPet", | |
| "tags": ["pets"], | |
| "requestBody": {"$ref": "#/components/requestBodies/PetBody"}, | |
| "responses": {"201": {"description": "Null response"}}, | |
| }, | |
| }, | |
| "/pets/{petId}": { | |
| "get": { | |
| "summary": "Info for a specific pet", | |
| "operationId": "showPetById", | |
| "tags": ["pets"], | |
| "parameters": [ | |
| { | |
| "name": "petId", | |
| "in": "path", | |
| "required": True, | |
| "description": "The id of the pet", | |
| "schema": {"type": "string"}, | |
| }, | |
| { | |
| "name": "X-Request-ID", | |
| "in": "header", | |
| "required": False, | |
| "schema": {"type": "string", "format": "uuid"}, | |
| }, | |
| ], | |
| "responses": {"200": {"description": "Information about the pet"}}, | |
| }, | |
| "parameters": [ # Path level parameter example | |
| { | |
| "name": "traceId", | |
| "in": "header", | |
| "description": "Common trace ID", | |
| "required": False, | |
| "schema": {"type": "string"}, | |
| } | |
| ], | |
| }, | |
| }, | |
| "components": { | |
| "schemas": { | |
| "Pet": { | |
| "type": "object", | |
| "required": ["id", "name"], | |
| "properties": { | |
| "id": {"type": "integer", "format": "int64"}, | |
| "name": {"type": "string"}, | |
| "tag": {"type": "string"}, | |
| }, | |
| } | |
| }, | |
| "requestBodies": { | |
| "PetBody": { | |
| "description": "Pet object", | |
| "required": True, | |
| "content": { | |
| "application/json": { | |
| "schema": {"$ref": "#/components/schemas/Pet"} | |
| } | |
| }, | |
| } | |
| }, | |
| }, | |
| } | |
| def parsed_petstore_routes(petstore_schema): | |
| """Return parsed routes from the PetStore schema.""" | |
| return parse_openapi_to_http_routes(petstore_schema) | |
| def bookstore_schema() -> dict[str, Any]: | |
| """Fixture that returns a Book Store API schema with different parameter types.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "Book Store API", "version": "1.0.0"}, | |
| "paths": { | |
| "/books": { | |
| "get": { | |
| "summary": "List all books", | |
| "operationId": "listBooks", | |
| "tags": ["books"], | |
| "parameters": [ | |
| { | |
| "name": "genre", | |
| "in": "query", | |
| "description": "Filter by genre", | |
| "required": False, | |
| "schema": {"type": "string"}, | |
| }, | |
| { | |
| "name": "published_after", | |
| "in": "query", | |
| "description": "Filter by publication date", | |
| "required": False, | |
| "schema": {"type": "string", "format": "date"}, | |
| }, | |
| { | |
| "name": "limit", | |
| "in": "query", | |
| "description": "Maximum number of results", | |
| "required": False, | |
| "schema": {"type": "integer", "default": 10}, | |
| }, | |
| ], | |
| "responses": {"200": {"description": "A list of books"}}, | |
| }, | |
| "post": { | |
| "summary": "Create a new book", | |
| "operationId": "createBook", | |
| "tags": ["books"], | |
| "requestBody": { | |
| "required": True, | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "object", | |
| "required": ["title", "author"], | |
| "properties": { | |
| "title": {"type": "string"}, | |
| "author": {"type": "string"}, | |
| "isbn": {"type": "string"}, | |
| "published": { | |
| "type": "string", | |
| "format": "date", | |
| }, | |
| "genre": {"type": "string"}, | |
| }, | |
| } | |
| } | |
| }, | |
| }, | |
| "responses": {"201": {"description": "Book created"}}, | |
| }, | |
| }, | |
| "/books/{isbn}": { | |
| "get": { | |
| "summary": "Get book by ISBN", | |
| "operationId": "getBook", | |
| "tags": ["books"], | |
| "parameters": [ | |
| { | |
| "name": "isbn", | |
| "in": "path", | |
| "required": True, | |
| "description": "ISBN of the book", | |
| "schema": {"type": "string"}, | |
| } | |
| ], | |
| "responses": {"200": {"description": "Book details"}}, | |
| }, | |
| "delete": { | |
| "summary": "Delete a book", | |
| "operationId": "deleteBook", | |
| "tags": ["books"], | |
| "parameters": [ | |
| { | |
| "name": "isbn", | |
| "in": "path", | |
| "required": True, | |
| "description": "ISBN of the book to delete", | |
| "schema": {"type": "string"}, | |
| } | |
| ], | |
| "responses": {"204": {"description": "Book deleted"}}, | |
| }, | |
| }, | |
| }, | |
| } | |
| def parsed_bookstore_routes(bookstore_schema): | |
| """Return parsed routes from the BookStore schema.""" | |
| return parse_openapi_to_http_routes(bookstore_schema) | |
| # --- FastAPI App Fixtures --- # | |
| class Item(BaseModel): | |
| """Example pydantic model for API testing.""" | |
| name: str | |
| description: str | None = None | |
| price: float | |
| tax: float | None = None | |
| tags: list[str] = Field(default_factory=list) | |
| def fastapi_app() -> FastAPI: | |
| """Fixture that returns a FastAPI app with various types of endpoints.""" | |
| app = FastAPI(title="Test API", version="1.0.0") | |
| async def list_items(skip: int = 0, limit: int = 10): | |
| """List all items with pagination.""" | |
| return [ | |
| {"name": f"Item {i}", "price": float(i)} for i in range(skip, skip + limit) | |
| ] | |
| async def create_item(item: Item): | |
| """Create a new item.""" | |
| return item | |
| async def get_item( | |
| item_id: int = Path(..., description="The ID of the item to get"), | |
| q: str | None = Query(None, description="Optional query string"), | |
| ): | |
| """Get an item by ID.""" | |
| return {"item_id": item_id, "q": q} | |
| async def update_item( | |
| item_id: int = Path(..., description="The ID of the item to update"), | |
| item: Item = Body(..., description="The updated item data"), | |
| ): | |
| """Update an existing item.""" | |
| return {"item_id": item_id, **item.model_dump()} | |
| async def delete_item( | |
| item_id: int = Path(..., description="The ID of the item to delete"), | |
| ): | |
| """Delete an item by ID.""" | |
| return {"item_id": item_id, "deleted": True} | |
| async def get_item_tag( | |
| item_id: int = Path(..., description="The ID of the item"), | |
| tag_id: str = Path(..., description="The ID of the tag"), | |
| ): | |
| """Get a specific tag for an item.""" | |
| return {"item_id": item_id, "tag_id": tag_id} | |
| async def upload_file( | |
| file_name: str = Query(..., description="Name of the file to upload"), | |
| content_type: str = Query(..., description="Content type of the file"), | |
| ): | |
| """Upload a file (dummy endpoint for testing query params with POST).""" | |
| return { | |
| "file_name": file_name, | |
| "content_type": content_type, | |
| "status": "uploaded", | |
| } | |
| return app | |
| def fastapi_openapi_schema(fastapi_app) -> dict[str, Any]: | |
| """Fixture that returns the OpenAPI schema of the FastAPI app.""" | |
| return fastapi_app.openapi() | |
| def parsed_fastapi_routes(fastapi_openapi_schema): | |
| """Return parsed routes from a FastAPI OpenAPI schema.""" | |
| return parse_openapi_to_http_routes(fastapi_openapi_schema) | |
| def fastapi_route_map(parsed_fastapi_routes): | |
| """Return a dictionary of routes by operation ID.""" | |
| return { | |
| r.operation_id: r for r in parsed_fastapi_routes if r.operation_id is not None | |
| } | |
| def openapi_30_schema() -> dict[str, Any]: | |
| """Fixture that returns a simple OpenAPI 3.0.0 schema.""" | |
| return { | |
| "openapi": "3.0.0", | |
| "info": {"title": "Simple API (OpenAPI 3.0)", "version": "1.0.0"}, | |
| "paths": { | |
| "/items": { | |
| "get": { | |
| "summary": "List all items", | |
| "operationId": "listItems", | |
| "parameters": [ | |
| { | |
| "name": "limit", | |
| "in": "query", | |
| "description": "How many items to return", | |
| "required": False, | |
| "schema": {"type": "integer"}, | |
| } | |
| ], | |
| "responses": {"200": {"description": "A list of items"}}, | |
| } | |
| } | |
| }, | |
| } | |
| def openapi_31_schema() -> dict[str, Any]: | |
| """Fixture that returns a simple OpenAPI 3.1.0 schema.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "Simple API (OpenAPI 3.1)", "version": "1.0.0"}, | |
| "paths": { | |
| "/items": { | |
| "get": { | |
| "summary": "List all items", | |
| "operationId": "listItems", | |
| "parameters": [ | |
| { | |
| "name": "limit", | |
| "in": "query", | |
| "description": "How many items to return", | |
| "required": False, | |
| "schema": {"type": "integer"}, | |
| } | |
| ], | |
| "responses": {"200": {"description": "A list of items"}}, | |
| } | |
| } | |
| }, | |
| } | |
| def openapi_30_with_references() -> dict[str, Any]: | |
| """OpenAPI 3.0 schema with references to test resolution.""" | |
| return { | |
| "openapi": "3.0.0", | |
| "info": {"title": "API with References (3.0)", "version": "1.0.0"}, | |
| "paths": { | |
| "/products": { | |
| "post": { | |
| "summary": "Create product", | |
| "operationId": "createProduct", | |
| "requestBody": { | |
| "content": { | |
| "application/json": { | |
| "schema": {"$ref": "#/components/schemas/Product"} | |
| } | |
| }, | |
| "required": True, | |
| }, | |
| "responses": { | |
| "201": { | |
| "description": "Product created", | |
| "content": { | |
| "application/json": { | |
| "schema": {"$ref": "#/components/schemas/Product"} | |
| } | |
| }, | |
| } | |
| }, | |
| } | |
| } | |
| }, | |
| "components": { | |
| "schemas": { | |
| "Product": { | |
| "type": "object", | |
| "required": ["name", "price"], | |
| "properties": { | |
| "id": {"type": "string", "format": "uuid"}, | |
| "name": {"type": "string"}, | |
| "price": {"type": "number"}, | |
| "category": {"$ref": "#/components/schemas/Category"}, | |
| }, | |
| }, | |
| "Category": { | |
| "type": "object", | |
| "properties": { | |
| "id": {"type": "integer"}, | |
| "name": {"type": "string"}, | |
| }, | |
| }, | |
| } | |
| }, | |
| } | |
| def openapi_31_with_references() -> dict[str, Any]: | |
| """OpenAPI 3.1 schema with references to test resolution.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "API with References (3.1)", "version": "1.0.0"}, | |
| "paths": { | |
| "/products": { | |
| "post": { | |
| "summary": "Create product", | |
| "operationId": "createProduct", | |
| "requestBody": { | |
| "content": { | |
| "application/json": { | |
| "schema": {"$ref": "#/components/schemas/Product"} | |
| } | |
| }, | |
| "required": True, | |
| }, | |
| "responses": { | |
| "201": { | |
| "description": "Product created", | |
| "content": { | |
| "application/json": { | |
| "schema": {"$ref": "#/components/schemas/Product"} | |
| } | |
| }, | |
| } | |
| }, | |
| } | |
| } | |
| }, | |
| "components": { | |
| "schemas": { | |
| "Product": { | |
| "type": "object", | |
| "required": ["name", "price"], | |
| "properties": { | |
| "id": {"type": "string", "format": "uuid"}, | |
| "name": {"type": "string"}, | |
| "price": {"type": "number"}, | |
| "category": {"$ref": "#/components/schemas/Category"}, | |
| }, | |
| }, | |
| "Category": { | |
| "type": "object", | |
| "properties": { | |
| "id": {"type": "integer"}, | |
| "name": {"type": "string"}, | |
| }, | |
| }, | |
| } | |
| }, | |
| } | |
| # --- Tests for PetStore schema --- # | |
| def test_petstore_route_count(parsed_petstore_routes): | |
| """Test that parsing the PetStore schema correctly identifies the number of routes.""" | |
| assert len(parsed_petstore_routes) == 3 | |
| def test_petstore_get_pets_operation_id(parsed_petstore_routes): | |
| """Test that GET /pets operation_id is correctly parsed.""" | |
| get_pets = next( | |
| (r for r in parsed_petstore_routes if r.method == "GET" and r.path == "/pets"), | |
| None, | |
| ) | |
| assert get_pets is not None | |
| assert get_pets.operation_id == "listPets" | |
| def test_petstore_query_parameter(parsed_petstore_routes): | |
| """Test that query parameter 'limit' is correctly parsed from the schema.""" | |
| get_pets = next( | |
| (r for r in parsed_petstore_routes if r.method == "GET" and r.path == "/pets"), | |
| None, | |
| ) | |
| assert get_pets is not None | |
| assert len(get_pets.parameters) == 1 | |
| param = get_pets.parameters[0] | |
| assert param.name == "limit" | |
| assert param.location == "query" | |
| assert param.required is False | |
| assert param.schema_.get("type") == "integer" | |
| assert param.schema_.get("format") == "int32" | |
| def test_petstore_path_parameter(parsed_petstore_routes): | |
| """Test that path parameter 'petId' is correctly parsed from the schema.""" | |
| get_pet = next( | |
| ( | |
| r | |
| for r in parsed_petstore_routes | |
| if r.method == "GET" and r.path == "/pets/{petId}" | |
| ), | |
| None, | |
| ) | |
| assert get_pet is not None | |
| path_param = next((p for p in get_pet.parameters if p.name == "petId"), None) | |
| assert path_param is not None | |
| assert path_param.location == "path" | |
| assert path_param.required is True | |
| assert path_param.schema_.get("type") == "string" | |
| def test_petstore_header_parameters(parsed_petstore_routes): | |
| """Test that header parameters are correctly parsed from the schema.""" | |
| get_pet = next( | |
| ( | |
| r | |
| for r in parsed_petstore_routes | |
| if r.method == "GET" and r.path == "/pets/{petId}" | |
| ), | |
| None, | |
| ) | |
| assert get_pet is not None | |
| header_params = [p for p in get_pet.parameters if p.location == "header"] | |
| assert len(header_params) == 2 | |
| def test_petstore_header_parameter_names(parsed_petstore_routes): | |
| """Test that header parameter names are correctly parsed.""" | |
| get_pet = next( | |
| ( | |
| r | |
| for r in parsed_petstore_routes | |
| if r.method == "GET" and r.path == "/pets/{petId}" | |
| ), | |
| None, | |
| ) | |
| assert get_pet is not None | |
| header_params = [p for p in get_pet.parameters if p.location == "header"] | |
| header_names = [p.name for p in header_params] | |
| assert "X-Request-ID" in header_names | |
| assert "traceId" in header_names | |
| def test_petstore_path_level_parameters(parsed_petstore_routes): | |
| """Test that path-level parameters are correctly merged into the operation.""" | |
| get_pet = next( | |
| ( | |
| r | |
| for r in parsed_petstore_routes | |
| if r.method == "GET" and r.path == "/pets/{petId}" | |
| ), | |
| None, | |
| ) | |
| assert get_pet is not None | |
| trace_param = next((p for p in get_pet.parameters if p.name == "traceId"), None) | |
| assert trace_param is not None | |
| assert trace_param.location == "header" | |
| assert trace_param.required is False | |
| def test_petstore_request_body_reference_resolution(parsed_petstore_routes): | |
| """Test that request body references are correctly resolved.""" | |
| create_pet = next( | |
| (r for r in parsed_petstore_routes if r.method == "POST" and r.path == "/pets"), | |
| None, | |
| ) | |
| assert create_pet is not None | |
| assert create_pet.request_body is not None | |
| assert create_pet.request_body.required is True | |
| assert "application/json" in create_pet.request_body.content_schema | |
| def test_petstore_schema_reference_resolution(parsed_petstore_routes): | |
| """Test that schema references in request bodies are correctly resolved.""" | |
| create_pet = next( | |
| (r for r in parsed_petstore_routes if r.method == "POST" and r.path == "/pets"), | |
| None, | |
| ) | |
| assert create_pet is not None | |
| assert create_pet.request_body is not None | |
| json_schema = create_pet.request_body.content_schema["application/json"] | |
| properties = json_schema.get("properties", {}) | |
| assert "id" in properties | |
| assert "name" in properties | |
| assert "tag" in properties | |
| def test_petstore_required_fields_resolution(parsed_petstore_routes): | |
| """Test that required fields are correctly resolved from referenced schemas.""" | |
| create_pet = next( | |
| (r for r in parsed_petstore_routes if r.method == "POST" and r.path == "/pets"), | |
| None, | |
| ) | |
| assert create_pet is not None | |
| assert create_pet.request_body is not None | |
| json_schema = create_pet.request_body.content_schema["application/json"] | |
| assert json_schema.get("required") == ["id", "name"] | |
| def test_tags_parsing_in_petstore_routes(parsed_petstore_routes): | |
| """Test that tags are correctly parsed from the OpenAPI schema.""" | |
| # All petstore routes should have the "pets" tag | |
| for route in parsed_petstore_routes: | |
| assert "pets" in route.tags, ( | |
| f"Route {route.method} {route.path} is missing 'pets' tag" | |
| ) | |
| def test_tag_list_structure(parsed_petstore_routes): | |
| """Test that tags are stored as a list of strings.""" | |
| for route in parsed_petstore_routes: | |
| assert isinstance(route.tags, list), "Tags should be stored as a list" | |
| for tag in route.tags: | |
| assert isinstance(tag, str), "Each tag should be a string" | |
| def test_empty_tags_handling(bookstore_schema): | |
| """Test that routes with no tags are handled correctly with empty lists.""" | |
| # Modify a route to remove tags | |
| if "tags" in bookstore_schema["paths"]["/books"]["get"]: | |
| del bookstore_schema["paths"]["/books"]["get"]["tags"] | |
| # Parse the modified schema | |
| routes = parse_openapi_to_http_routes(bookstore_schema) | |
| # Find the GET /books route | |
| get_books = next( | |
| (r for r in routes if r.method == "GET" and r.path == "/books"), None | |
| ) | |
| assert get_books is not None | |
| # Should have an empty list, not None | |
| assert get_books.tags == [], "Routes without tags should have empty tag lists" | |
| def test_multiple_tags_preserved(bookstore_schema): | |
| """Test that multiple tags are preserved during parsing.""" | |
| # Add multiple tags to a route | |
| bookstore_schema["paths"]["/books"]["get"]["tags"] = ["books", "catalog", "api"] | |
| # Parse the modified schema | |
| routes = parse_openapi_to_http_routes(bookstore_schema) | |
| # Find the GET /books route | |
| get_books = next( | |
| (r for r in routes if r.method == "GET" and r.path == "/books"), None | |
| ) | |
| assert get_books is not None | |
| # Should have all tags | |
| assert "books" in get_books.tags | |
| assert "catalog" in get_books.tags | |
| assert "api" in get_books.tags | |
| assert len(get_books.tags) == 3 | |
| def test_openapi_extensions(petstore_schema): | |
| """Test that OpenAPI extensions (x-*) are correctly parsed from operations.""" | |
| # Add extensions to a route | |
| petstore_schema["paths"]["/pets"]["get"]["x-rate-limit"] = 100 | |
| petstore_schema["paths"]["/pets"]["get"]["x-custom-auth"] = "bearer" | |
| petstore_schema["paths"]["/pets"]["get"]["x-internal"] = True | |
| # Parse the modified schema | |
| routes = parse_openapi_to_http_routes(petstore_schema) | |
| # Find the GET /pets route | |
| get_pets = next( | |
| (r for r in routes if r.method == "GET" and r.path == "/pets"), None | |
| ) | |
| assert get_pets is not None | |
| # Should have extensions | |
| assert get_pets.extensions["x-rate-limit"] == 100 | |
| assert get_pets.extensions["x-custom-auth"] == "bearer" | |
| assert get_pets.extensions["x-internal"] is True | |
| assert len(get_pets.extensions) == 3 | |
| # --- Tests for BookStore schema --- # | |
| def test_bookstore_route_count(parsed_bookstore_routes): | |
| """Test that parsing the BookStore schema correctly identifies the number of routes.""" | |
| assert len(parsed_bookstore_routes) == 4 | |
| def test_bookstore_query_parameter_count(parsed_bookstore_routes): | |
| """Test that the correct number of query parameters are parsed.""" | |
| list_books = next( | |
| (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None | |
| ) | |
| assert list_books is not None | |
| assert len(list_books.parameters) == 3 | |
| def test_bookstore_query_parameter_names(parsed_bookstore_routes): | |
| """Test that query parameter names are correctly parsed.""" | |
| list_books = next( | |
| (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None | |
| ) | |
| assert list_books is not None | |
| param_map = {p.name: p for p in list_books.parameters} | |
| assert "genre" in param_map | |
| assert "published_after" in param_map | |
| assert "limit" in param_map | |
| def test_bookstore_query_parameter_formats(parsed_bookstore_routes): | |
| """Test that query parameter formats are correctly parsed.""" | |
| list_books = next( | |
| (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None | |
| ) | |
| assert list_books is not None | |
| param_map = {p.name: p for p in list_books.parameters} | |
| assert param_map["published_after"].schema_.get("format") == "date" | |
| def test_bookstore_query_parameter_defaults(parsed_bookstore_routes): | |
| """Test that query parameter default values are correctly parsed.""" | |
| list_books = next( | |
| (r for r in parsed_bookstore_routes if r.operation_id == "listBooks"), None | |
| ) | |
| assert list_books is not None | |
| param_map = {p.name: p for p in list_books.parameters} | |
| assert param_map["limit"].schema_.get("default") == 10 | |
| def test_bookstore_inline_request_body_presence(parsed_bookstore_routes): | |
| """Test that request bodies with inline schemas are present.""" | |
| create_book = next( | |
| (r for r in parsed_bookstore_routes if r.operation_id == "createBook"), None | |
| ) | |
| assert create_book is not None | |
| assert create_book.request_body is not None | |
| assert create_book.request_body.required is True | |
| assert "application/json" in create_book.request_body.content_schema | |
| def test_bookstore_inline_request_body_properties(parsed_bookstore_routes): | |
| """Test that request body properties are correctly parsed from inline schemas.""" | |
| create_book = next( | |
| (r for r in parsed_bookstore_routes if r.operation_id == "createBook"), None | |
| ) | |
| assert create_book is not None | |
| assert create_book.request_body is not None | |
| json_schema = create_book.request_body.content_schema["application/json"] | |
| properties = json_schema.get("properties", {}) | |
| assert "title" in properties | |
| assert "author" in properties | |
| assert "isbn" in properties | |
| assert "published" in properties | |
| assert "genre" in properties | |
| def test_bookstore_inline_request_body_required_fields(parsed_bookstore_routes): | |
| """Test that required fields in inline schema are correctly parsed.""" | |
| create_book = next( | |
| (r for r in parsed_bookstore_routes if r.operation_id == "createBook"), None | |
| ) | |
| assert create_book is not None | |
| assert create_book.request_body is not None | |
| json_schema = create_book.request_body.content_schema["application/json"] | |
| assert json_schema.get("required") == ["title", "author"] | |
| def test_bookstore_delete_method(parsed_bookstore_routes): | |
| """Test that DELETE method is correctly parsed from the schema.""" | |
| delete_book = next( | |
| (r for r in parsed_bookstore_routes if r.method == "DELETE"), None | |
| ) | |
| assert delete_book is not None | |
| assert delete_book.operation_id == "deleteBook" | |
| assert delete_book.path == "/books/{isbn}" | |
| def test_bookstore_delete_method_parameters(parsed_bookstore_routes): | |
| """Test that parameters for DELETE method are correctly parsed.""" | |
| delete_book = next( | |
| (r for r in parsed_bookstore_routes if r.method == "DELETE"), None | |
| ) | |
| assert delete_book is not None | |
| assert len(delete_book.parameters) == 1 | |
| assert delete_book.parameters[0].name == "isbn" | |
| # --- Tests for FastAPI Generated Schema --- # | |
| def test_fastapi_route_count(parsed_fastapi_routes): | |
| """Test that parsing a FastAPI-generated schema correctly identifies the number of routes.""" | |
| assert len(parsed_fastapi_routes) == 7 | |
| def test_fastapi_parameter_default_values(fastapi_route_map): | |
| """Test that default parameter values are correctly parsed from the schema.""" | |
| list_items = fastapi_route_map["list_items"] | |
| param_map = {p.name: p for p in list_items.parameters} | |
| assert "skip" in param_map | |
| assert "limit" in param_map | |
| def test_fastapi_skip_parameter_default(fastapi_route_map): | |
| """Test that skip parameter default value is correctly parsed.""" | |
| list_items = fastapi_route_map["list_items"] | |
| param_map = {p.name: p for p in list_items.parameters} | |
| assert param_map["skip"].schema_.get("default") == 0 | |
| def test_fastapi_limit_parameter_default(fastapi_route_map): | |
| """Test that limit parameter default value is correctly parsed.""" | |
| list_items = fastapi_route_map["list_items"] | |
| param_map = {p.name: p for p in list_items.parameters} | |
| assert param_map["limit"].schema_.get("default") == 10 | |
| def test_fastapi_request_body_from_pydantic(fastapi_route_map): | |
| """Test that request bodies from Pydantic models are present.""" | |
| create_item = fastapi_route_map["create_item"] | |
| assert create_item.request_body is not None | |
| assert "application/json" in create_item.request_body.content_schema | |
| def test_fastapi_request_body_properties(fastapi_route_map): | |
| """Test that request body properties from Pydantic models are correctly parsed.""" | |
| create_item = fastapi_route_map["create_item"] | |
| json_schema = create_item.request_body.content_schema["application/json"] | |
| properties = json_schema.get("properties", {}) | |
| assert "name" in properties | |
| assert "description" in properties | |
| assert "price" in properties | |
| assert "tax" in properties | |
| assert "tags" in properties | |
| def test_fastapi_request_body_required_fields(fastapi_route_map): | |
| """Test that required fields from Pydantic models are correctly parsed.""" | |
| create_item = fastapi_route_map["create_item"] | |
| json_schema = create_item.request_body.content_schema["application/json"] | |
| required = json_schema.get("required", []) | |
| assert "name" in required | |
| assert "price" in required | |
| def test_fastapi_path_parameter_presence(fastapi_route_map): | |
| """Test that path parameters are present in FastAPI schema.""" | |
| get_item = fastapi_route_map["get_item"] | |
| path_params = [p for p in get_item.parameters if p.location == "path"] | |
| assert len(path_params) == 1 | |
| def test_fastapi_path_parameter_properties(fastapi_route_map): | |
| """Test that path parameters properties are correctly parsed.""" | |
| get_item = fastapi_route_map["get_item"] | |
| path_params = [p for p in get_item.parameters if p.location == "path"] | |
| assert path_params[0].name == "item_id" | |
| assert path_params[0].required is True | |
| def test_fastapi_optional_query_parameter(fastapi_route_map): | |
| """Test that optional query parameters are correctly parsed.""" | |
| get_item = fastapi_route_map["get_item"] | |
| query_params = [p for p in get_item.parameters if p.location == "query"] | |
| assert len(query_params) == 1 | |
| assert query_params[0].name == "q" | |
| assert query_params[0].required is False | |
| def test_fastapi_multiple_path_parameter_count(fastapi_route_map): | |
| """Test that multiple path parameters count is correct.""" | |
| get_item_tag = fastapi_route_map["get_item_tag"] | |
| path_params = [p for p in get_item_tag.parameters if p.location == "path"] | |
| assert len(path_params) == 2 | |
| def test_fastapi_multiple_path_parameter_names(fastapi_route_map): | |
| """Test that multiple path parameter names are correctly parsed.""" | |
| get_item_tag = fastapi_route_map["get_item_tag"] | |
| path_params = [p for p in get_item_tag.parameters if p.location == "path"] | |
| param_names = [p.name for p in path_params] | |
| assert "item_id" in param_names | |
| assert "tag_id" in param_names | |
| def test_fastapi_post_with_query_parameters(fastapi_route_map): | |
| """Test that query parameters for POST methods are correctly parsed.""" | |
| upload_file = fastapi_route_map["upload_file"] | |
| assert upload_file.method == "POST" | |
| query_params = [p for p in upload_file.parameters if p.location == "query"] | |
| assert len(query_params) == 2 | |
| def test_fastapi_post_query_parameter_names(fastapi_route_map): | |
| """Test that query parameter names for POST methods are correctly parsed.""" | |
| upload_file = fastapi_route_map["upload_file"] | |
| query_params = [p for p in upload_file.parameters if p.location == "query"] | |
| param_names = [p.name for p in query_params] | |
| assert "file_name" in param_names | |
| assert "content_type" in param_names | |
| def test_openapi_30_compatibility(openapi_30_schema): | |
| """Test that OpenAPI 3.0 schemas can be parsed correctly.""" | |
| # This will raise an exception if the parser doesn't support 3.0.0 | |
| routes = parse_openapi_to_http_routes(openapi_30_schema) | |
| # Verify the route was parsed correctly | |
| assert len(routes) == 1 | |
| route = routes[0] | |
| assert route.method == "GET" | |
| assert route.path == "/items" | |
| assert route.operation_id == "listItems" | |
| assert len(route.parameters) == 1 | |
| assert route.parameters[0].name == "limit" | |
| def test_openapi_31_compatibility(openapi_31_schema): | |
| """Test that OpenAPI 3.1 schemas can be parsed correctly.""" | |
| routes = parse_openapi_to_http_routes(openapi_31_schema) | |
| # Verify the route was parsed correctly | |
| assert len(routes) == 1 | |
| route = routes[0] | |
| assert route.method == "GET" | |
| assert route.path == "/items" | |
| assert route.operation_id == "listItems" | |
| assert len(route.parameters) == 1 | |
| assert route.parameters[0].name == "limit" | |
| def test_version_detection_logic(): | |
| """Test that the version detection logic correctly identifies 3.0 vs 3.1 schemas.""" | |
| # Test 3.0 variations | |
| for version in ["3.0.0", "3.0.1", "3.0.3"]: | |
| schema = { | |
| "openapi": version, | |
| "info": {"title": "Test", "version": "1.0.0"}, | |
| "paths": {}, | |
| } | |
| try: | |
| parse_openapi_to_http_routes(schema) | |
| # Expect no error | |
| except Exception as e: | |
| pytest.fail(f"Failed to parse OpenAPI {version} schema: {e}") | |
| # Test 3.1 variations | |
| for version in ["3.1.0", "3.1.1"]: | |
| schema = { | |
| "openapi": version, | |
| "info": {"title": "Test", "version": "1.0.0"}, | |
| "paths": {}, | |
| } | |
| try: | |
| parse_openapi_to_http_routes(schema) | |
| # Expect no error | |
| except Exception as e: | |
| pytest.fail(f"Failed to parse OpenAPI {version} schema: {e}") | |
| def test_openapi_30_reference_resolution(openapi_30_with_references): | |
| """Test that references are correctly resolved in OpenAPI 3.0 schemas.""" | |
| routes = parse_openapi_to_http_routes(openapi_30_with_references) | |
| assert len(routes) == 1 | |
| route = routes[0] | |
| assert route.method == "POST" | |
| assert route.path == "/products" | |
| # Check request body | |
| assert route.request_body is not None | |
| assert route.request_body.required is True | |
| assert "application/json" in route.request_body.content_schema | |
| # Check schema structure | |
| json_schema = route.request_body.content_schema["application/json"] | |
| assert json_schema["type"] == "object" | |
| assert "properties" in json_schema | |
| assert set(json_schema["required"]) == {"name", "price"} | |
| # Check primary fields are properly resolved | |
| props = json_schema["properties"] | |
| assert "id" in props | |
| assert "name" in props | |
| assert "price" in props | |
| assert "category" in props | |
| # The category might be a reference or resolved object | |
| category = props["category"] | |
| # Either it's directly resolved with properties | |
| # or it still has a $ref field | |
| assert "properties" in category or "$ref" in category | |
| combined_schema = _combine_schemas(route) | |
| assert "#/$defs/" in combined_schema["properties"]["category"]["$ref"] | |
| def test_openapi_31_reference_resolution(openapi_31_with_references): | |
| """Test that references are correctly resolved in OpenAPI 3.1 schemas.""" | |
| routes = parse_openapi_to_http_routes(openapi_31_with_references) | |
| assert len(routes) == 1 | |
| route = routes[0] | |
| assert route.method == "POST" | |
| assert route.path == "/products" | |
| # Check request body | |
| assert route.request_body is not None | |
| assert route.request_body.required is True | |
| assert "application/json" in route.request_body.content_schema | |
| # Check schema structure | |
| json_schema = route.request_body.content_schema["application/json"] | |
| assert json_schema["type"] == "object" | |
| assert "properties" in json_schema | |
| assert set(json_schema["required"]) == {"name", "price"} | |
| # Check primary fields are properly resolved | |
| props = json_schema["properties"] | |
| assert "id" in props | |
| assert "name" in props | |
| assert "price" in props | |
| assert "category" in props | |
| # The category might be a reference or resolved object | |
| category = props["category"] | |
| # Either it's directly resolved with properties | |
| # or it still has a $ref field | |
| assert "properties" in category or "$ref" in category | |
| combined_schema = _combine_schemas(route) | |
| assert "#/$defs/" in combined_schema["properties"]["category"]["$ref"] | |
| def test_consistent_output_across_versions( | |
| openapi_30_with_references, openapi_31_with_references | |
| ): | |
| """Test that both parsers produce equivalent output for equivalent schemas.""" | |
| routes_30 = parse_openapi_to_http_routes(openapi_30_with_references) | |
| routes_31 = parse_openapi_to_http_routes(openapi_31_with_references) | |
| # Convert to dict for easier comparison | |
| route_30_dict = routes_30[0].model_dump(exclude_none=True) | |
| route_31_dict = routes_31[0].model_dump(exclude_none=True) | |
| # They should be identical except for version-specific differences | |
| # Compare path | |
| assert route_30_dict["path"] == route_31_dict["path"] | |
| # Compare method | |
| assert route_30_dict["method"] == route_31_dict["method"] | |
| # Compare operation_id | |
| assert route_30_dict["operation_id"] == route_31_dict["operation_id"] | |
| # Compare parameters | |
| assert len(route_30_dict["parameters"]) == len(route_31_dict["parameters"]) | |
| # Compare request body | |
| assert ( | |
| route_30_dict["request_body"]["required"] | |
| == route_31_dict["request_body"]["required"] | |
| ) | |
| # Compare response structure | |
| assert "201" in route_30_dict["responses"] and "201" in route_31_dict["responses"] | |
| # The schemas should contain the same essential fields | |
| schema_30 = route_30_dict["request_body"]["content_schema"]["application/json"][ | |
| "properties" | |
| ] | |
| schema_31 = route_31_dict["request_body"]["content_schema"]["application/json"][ | |
| "properties" | |
| ] | |
| assert set(schema_30.keys()) == set(schema_31.keys()) | |
| class TestReplaceRefWithDefs: | |
| def schemas(self): | |
| """Provide test schemas for _replace_ref_with_defs function.""" | |
| return { | |
| "ref_type": { | |
| "$ref": "#/components/schemas/RefFoo", | |
| }, | |
| "object_type": { | |
| "type": "object", | |
| "properties": {"$ref": "#/components/schemas/ObjectFoo"}, | |
| }, | |
| "array_type": { | |
| "type": "array", | |
| "items": {"$ref": "#/components/schemas/ArrayFoo"}, | |
| }, | |
| "any_of_type": { | |
| "anyOf": [ | |
| {"$ref": "#/components/schemas/AnyOfFoo"}, | |
| {"$ref": "#/components/schemas/AnyOfBar"}, | |
| ] | |
| }, | |
| "all_of_type": { | |
| "allOf": [ | |
| {"$ref": "#/components/schemas/AllOfFoo"}, | |
| {"$ref": "#/components/schemas/AllOfBar"}, | |
| ] | |
| }, | |
| "one_of_type": { | |
| "oneOf": [ | |
| {"$ref": "#/components/schemas/OneOfFoo"}, | |
| {"$ref": "#/components/schemas/OneOfBar"}, | |
| ] | |
| }, | |
| "nested_type": { | |
| "type": "object", | |
| "properties": { | |
| "pets": { | |
| "oneOf": [ | |
| {"$ref": "#/components/schemas/Cat"}, | |
| {"$ref": "#/components/schemas/Dog"}, | |
| ] | |
| }, | |
| }, | |
| }, | |
| } | |
| def test_replace_direct_ref(self, schemas): | |
| """Test replacing direct $ref references.""" | |
| result = _replace_ref_with_defs(schemas["ref_type"]) | |
| assert result == {"$ref": "#/$defs/RefFoo"} | |
| def test_replace_object_property_ref(self, schemas): | |
| """Test replacing $ref in object properties.""" | |
| result = _replace_ref_with_defs(schemas["object_type"]) | |
| assert result == { | |
| "type": "object", | |
| "properties": {"$ref": "#/$defs/ObjectFoo"}, | |
| } | |
| def test_replace_array_items_ref(self, schemas): | |
| """Test replacing $ref in array items.""" | |
| result = _replace_ref_with_defs(schemas["array_type"]) | |
| assert result == { | |
| "type": "array", | |
| "items": {"$ref": "#/$defs/ArrayFoo"}, | |
| } | |
| def test_replace_any_of_refs(self, schemas): | |
| """Test replacing $ref in anyOf schemas.""" | |
| result = _replace_ref_with_defs(schemas["any_of_type"]) | |
| assert result == { | |
| "anyOf": [{"$ref": "#/$defs/AnyOfFoo"}, {"$ref": "#/$defs/AnyOfBar"}] | |
| } | |
| def test_replace_all_of_refs(self, schemas): | |
| """Test replacing $ref in allOf schemas.""" | |
| result = _replace_ref_with_defs(schemas["all_of_type"]) | |
| assert result == { | |
| "allOf": [{"$ref": "#/$defs/AllOfFoo"}, {"$ref": "#/$defs/AllOfBar"}] | |
| } | |
| def test_replace_one_of_refs(self, schemas): | |
| """Test replacing $ref in oneOf schemas.""" | |
| result = _replace_ref_with_defs(schemas["one_of_type"]) | |
| assert result == { | |
| "oneOf": [{"$ref": "#/$defs/OneOfFoo"}, {"$ref": "#/$defs/OneOfBar"}] | |
| } | |
| def test_replace_nested_refs(self, schemas): | |
| """Test replacing $ref in deeply nested schema structures.""" | |
| result = _replace_ref_with_defs(schemas["nested_type"]) | |
| assert result == { | |
| "type": "object", | |
| "properties": { | |
| "pets": {"oneOf": [{"$ref": "#/$defs/Cat"}, {"$ref": "#/$defs/Dog"}]} | |
| }, | |
| } | |