Spaces:
Running
Running
| """Tests for advanced features of the OpenAPI utilities.""" | |
| from typing import Any | |
| import pytest | |
| from fastmcp.utilities.openapi import parse_openapi_to_http_routes | |
| def complex_schema() -> dict[str, Any]: | |
| """Fixture that returns a complex OpenAPI schema with nested references.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "Complex API", "version": "1.0.0"}, | |
| "paths": { | |
| "/users": { | |
| "get": { | |
| "summary": "List all users", | |
| "operationId": "listUsers", | |
| "parameters": [ | |
| {"$ref": "#/components/parameters/PageLimit"}, | |
| {"$ref": "#/components/parameters/PageOffset"}, | |
| ], | |
| "responses": {"200": {"description": "A list of users"}}, | |
| } | |
| }, | |
| "/users/{userId}": { | |
| "get": { | |
| "summary": "Get user by ID", | |
| "operationId": "getUser", | |
| "parameters": [ | |
| {"$ref": "#/components/parameters/UserId"}, | |
| {"$ref": "#/components/parameters/IncludeInactive"}, | |
| ], | |
| "responses": {"200": {"description": "User details"}}, | |
| } | |
| }, | |
| "/users/{userId}/orders": { | |
| "post": { | |
| "summary": "Create order for user", | |
| "operationId": "createOrder", | |
| "parameters": [{"$ref": "#/components/parameters/UserId"}], | |
| "requestBody": {"$ref": "#/components/requestBodies/OrderRequest"}, | |
| "responses": {"201": {"description": "Order created"}}, | |
| } | |
| }, | |
| }, | |
| "components": { | |
| "parameters": { | |
| "UserId": { | |
| "name": "userId", | |
| "in": "path", | |
| "required": True, | |
| "schema": {"type": "string", "format": "uuid"}, | |
| }, | |
| "PageLimit": { | |
| "name": "limit", | |
| "in": "query", | |
| "schema": {"type": "integer", "default": 20, "maximum": 100}, | |
| }, | |
| "PageOffset": { | |
| "name": "offset", | |
| "in": "query", | |
| "schema": {"type": "integer", "default": 0}, | |
| }, | |
| "IncludeInactive": { | |
| "name": "include_inactive", | |
| "in": "query", | |
| "schema": {"type": "boolean", "default": False}, | |
| }, | |
| }, | |
| "schemas": { | |
| "User": { | |
| "type": "object", | |
| "properties": { | |
| "id": {"type": "string", "format": "uuid"}, | |
| "name": {"type": "string"}, | |
| "email": {"type": "string", "format": "email"}, | |
| "role": {"$ref": "#/components/schemas/Role"}, | |
| "address": {"$ref": "#/components/schemas/Address"}, | |
| }, | |
| }, | |
| "Role": { | |
| "type": "string", | |
| "enum": ["admin", "user", "guest"], | |
| }, | |
| "Address": { | |
| "type": "object", | |
| "properties": { | |
| "street": {"type": "string"}, | |
| "city": {"type": "string"}, | |
| "zip": {"type": "string"}, | |
| "country": {"type": "string"}, | |
| }, | |
| }, | |
| "Order": { | |
| "type": "object", | |
| "properties": { | |
| "id": {"type": "string", "format": "uuid"}, | |
| "items": { | |
| "type": "array", | |
| "items": {"$ref": "#/components/schemas/OrderItem"}, | |
| }, | |
| "total": {"type": "number"}, | |
| "status": {"$ref": "#/components/schemas/OrderStatus"}, | |
| }, | |
| }, | |
| "OrderItem": { | |
| "type": "object", | |
| "properties": { | |
| "product_id": {"type": "string", "format": "uuid"}, | |
| "quantity": {"type": "integer"}, | |
| "price": {"type": "number"}, | |
| }, | |
| }, | |
| "OrderStatus": { | |
| "type": "string", | |
| "enum": [ | |
| "pending", | |
| "processing", | |
| "shipped", | |
| "delivered", | |
| "cancelled", | |
| ], | |
| }, | |
| }, | |
| "requestBodies": { | |
| "OrderRequest": { | |
| "description": "Order to create", | |
| "required": True, | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "object", | |
| "required": ["items"], | |
| "properties": { | |
| "items": { | |
| "type": "array", | |
| "items": { | |
| "$ref": "#/components/schemas/OrderItem" | |
| }, | |
| }, | |
| "notes": {"type": "string"}, | |
| }, | |
| } | |
| } | |
| }, | |
| } | |
| }, | |
| }, | |
| } | |
| def parsed_complex_routes(complex_schema): | |
| """Return parsed routes from the complex schema.""" | |
| return parse_openapi_to_http_routes(complex_schema) | |
| def complex_route_map(parsed_complex_routes): | |
| """Return a dictionary of routes by operation ID.""" | |
| return { | |
| r.operation_id: r for r in parsed_complex_routes if r.operation_id is not None | |
| } | |
| def schema_with_invalid_reference() -> dict[str, Any]: | |
| """Fixture that returns a schema with an invalid reference.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "Invalid Reference API", "version": "1.0.0"}, | |
| "paths": { | |
| "/broken-ref": { | |
| "get": { | |
| "summary": "Endpoint with broken reference", | |
| "operationId": "brokenRef", | |
| "parameters": [ | |
| {"$ref": "#/components/parameters/NonExistentParam"} | |
| ], | |
| "responses": {"200": {"description": "Something"}}, | |
| } | |
| } | |
| }, | |
| "components": { | |
| "parameters": {} # Empty parameters object to ensure the reference is broken | |
| }, | |
| } | |
| def schema_with_content_params() -> dict[str, Any]: | |
| """Fixture that returns a schema with content-based parameters (complex parameters).""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "Content Params API", "version": "1.0.0"}, | |
| "paths": { | |
| "/complex-params": { | |
| "post": { | |
| "summary": "Endpoint with complex parameter", | |
| "operationId": "complexParams", | |
| "parameters": [ | |
| { | |
| "name": "filter", | |
| "in": "query", | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "object", | |
| "properties": { | |
| "field": {"type": "string"}, | |
| "operator": { | |
| "type": "string", | |
| "enum": ["eq", "gt", "lt"], | |
| }, | |
| "value": {"type": "string"}, | |
| }, | |
| } | |
| } | |
| }, | |
| } | |
| ], | |
| "responses": {"200": {"description": "Results"}}, | |
| } | |
| }, | |
| }, | |
| } | |
| def parsed_content_param_routes(schema_with_content_params): | |
| """Return parsed routes from the schema with content parameters.""" | |
| return parse_openapi_to_http_routes(schema_with_content_params) | |
| def schema_all_http_methods() -> dict[str, Any]: | |
| """Fixture that returns a schema with all HTTP methods.""" | |
| return { | |
| "openapi": "3.1.0", | |
| "info": {"title": "All Methods API", "version": "1.0.0"}, | |
| "paths": { | |
| "/resource": { | |
| "get": { | |
| "operationId": "getResource", | |
| "responses": {"200": {"description": "Success"}}, | |
| }, | |
| "post": { | |
| "operationId": "createResource", | |
| "responses": {"201": {"description": "Created"}}, | |
| }, | |
| "put": { | |
| "operationId": "updateResource", | |
| "responses": {"200": {"description": "Updated"}}, | |
| }, | |
| "delete": { | |
| "operationId": "deleteResource", | |
| "responses": {"204": {"description": "Deleted"}}, | |
| }, | |
| "patch": { | |
| "operationId": "patchResource", | |
| "responses": {"200": {"description": "Patched"}}, | |
| }, | |
| "head": { | |
| "operationId": "headResource", | |
| "responses": {"200": {"description": "Headers only"}}, | |
| }, | |
| "options": { | |
| "operationId": "optionsResource", | |
| "responses": {"200": {"description": "Options"}}, | |
| }, | |
| "trace": { | |
| "operationId": "traceResource", | |
| "responses": {"200": {"description": "Trace"}}, | |
| }, | |
| }, | |
| }, | |
| } | |
| def parsed_http_methods_routes(schema_all_http_methods): | |
| """Return parsed routes from the schema with all HTTP methods.""" | |
| return parse_openapi_to_http_routes(schema_all_http_methods) | |
| # --- Tests for complex schemas with references --- # | |
| def test_complex_schema_route_count(parsed_complex_routes): | |
| """Test that parsing a schema with references successfully extracts all routes.""" | |
| assert len(parsed_complex_routes) == 3 | |
| def test_complex_schema_ref_rewriting(parsed_complex_routes): | |
| """Test that all #/components references have been rewritten.""" | |
| def no_components(value): | |
| if isinstance(value, dict): | |
| for k, v in value.items(): | |
| if k == "$ref": | |
| assert not v.startswith("#/components/"), ( | |
| f"reference '{v}' was not rewritten" | |
| ) | |
| else: | |
| no_components(v) | |
| elif isinstance(value, list): | |
| for v in value: | |
| no_components(v) | |
| for route in parsed_complex_routes: | |
| no_components(route.schema_definitions) | |
| for param in route.parameters: | |
| no_components(param.schema_) | |
| def test_complex_schema_list_users_query_param_limit(complex_route_map): | |
| """Test that a reference to a limit query parameter is correctly resolved.""" | |
| list_users = complex_route_map["listUsers"] | |
| limit_param = next((p for p in list_users.parameters if p.name == "limit"), None) | |
| assert limit_param is not None | |
| assert limit_param.location == "query" | |
| assert limit_param.schema_.get("default") == 20 | |
| def test_complex_schema_list_users_query_param_limit_maximum(complex_route_map): | |
| """Test that a limit parameter's maximum value is correctly resolved.""" | |
| list_users = complex_route_map["listUsers"] | |
| limit_param = next((p for p in list_users.parameters if p.name == "limit"), None) | |
| assert limit_param is not None | |
| assert limit_param.schema_.get("maximum") == 100 | |
| def test_complex_schema_get_user_path_param_existence(complex_route_map): | |
| """Test that a reference to a path parameter exists.""" | |
| get_user = complex_route_map["getUser"] | |
| user_id_param = next((p for p in get_user.parameters if p.name == "userId"), None) | |
| assert user_id_param is not None | |
| assert user_id_param.location == "path" | |
| def test_complex_schema_get_user_path_param_required(complex_route_map): | |
| """Test that a path parameter is correctly marked as required.""" | |
| get_user = complex_route_map["getUser"] | |
| user_id_param = next((p for p in get_user.parameters if p.name == "userId"), None) | |
| assert user_id_param is not None | |
| assert user_id_param.required is True | |
| def test_complex_schema_get_user_path_param_format(complex_route_map): | |
| """Test that a path parameter format is correctly resolved.""" | |
| get_user = complex_route_map["getUser"] | |
| user_id_param = next((p for p in get_user.parameters if p.name == "userId"), None) | |
| assert user_id_param is not None | |
| assert user_id_param.schema_.get("format") == "uuid" | |
| def test_complex_schema_create_order_request_body_presence(complex_route_map): | |
| """Test that a reference to a request body is resolved correctly.""" | |
| create_order = complex_route_map["createOrder"] | |
| assert create_order.request_body is not None | |
| assert create_order.request_body.required is True | |
| def test_complex_schema_create_order_request_body_content_type(complex_route_map): | |
| """Test that request body content type is correctly resolved.""" | |
| create_order = complex_route_map["createOrder"] | |
| assert create_order.request_body is not None | |
| assert "application/json" in create_order.request_body.content_schema | |
| def test_complex_schema_create_order_request_body_properties(complex_route_map): | |
| """Test that request body properties are correctly resolved.""" | |
| create_order = complex_route_map["createOrder"] | |
| assert create_order.request_body is not None | |
| json_schema = create_order.request_body.content_schema["application/json"] | |
| assert "items" in json_schema.get("properties", {}) | |
| def test_complex_schema_create_order_request_body_required_fields(complex_route_map): | |
| """Test that request body required fields are correctly resolved.""" | |
| create_order = complex_route_map["createOrder"] | |
| assert create_order.request_body is not None | |
| json_schema = create_order.request_body.content_schema["application/json"] | |
| assert json_schema.get("required") == ["items"] | |
| # --- Tests for schema reference resolution errors --- # | |
| def test_parser_handles_broken_references(schema_with_invalid_reference): | |
| """Test that parser handles broken references gracefully.""" | |
| # We're just checking that the function doesn't throw an exception | |
| routes = parse_openapi_to_http_routes(schema_with_invalid_reference) | |
| # Should still return routes list (may be empty) | |
| assert isinstance(routes, list) | |
| # Verify that the route with broken parameter reference is still included | |
| # though it may not have the parameter properly | |
| broken_route = next( | |
| (r for r in routes if r.path == "/broken-ref" and r.method == "GET"), None | |
| ) | |
| # The route should still be present | |
| assert broken_route is not None | |
| assert broken_route.operation_id == "brokenRef" | |
| # --- Tests for content-based parameters --- # | |
| def test_content_param_parameter_name(parsed_content_param_routes): | |
| """Test that parser correctly extracts name for content-based parameters.""" | |
| complex_params = parsed_content_param_routes[0] | |
| assert len(complex_params.parameters) == 1 | |
| param = complex_params.parameters[0] | |
| assert param.name == "filter" | |
| def test_content_param_parameter_location(parsed_content_param_routes): | |
| """Test that parser correctly extracts location for content-based parameters.""" | |
| complex_params = parsed_content_param_routes[0] | |
| assert len(complex_params.parameters) == 1 | |
| param = complex_params.parameters[0] | |
| assert param.location == "query" | |
| def test_content_param_schema_properties_presence(parsed_content_param_routes): | |
| """Test that parser extracts schema properties from content-based parameter.""" | |
| complex_params = parsed_content_param_routes[0] | |
| param = complex_params.parameters[0] | |
| properties = param.schema_.get("properties", {}) | |
| assert "field" in properties | |
| assert "operator" in properties | |
| assert "value" in properties | |
| def test_content_param_schema_enum_presence(parsed_content_param_routes): | |
| """Test that parser extracts enum values from content-based parameter.""" | |
| complex_params = parsed_content_param_routes[0] | |
| param = complex_params.parameters[0] | |
| properties = param.schema_.get("properties", {}) | |
| assert "enum" in properties.get("operator", {}) | |
| # --- Tests for HTTP methods --- # | |
| def test_http_get_method_presence(parsed_http_methods_routes): | |
| """Test that GET method is correctly extracted.""" | |
| get_route = next((r for r in parsed_http_methods_routes if r.method == "GET"), None) | |
| assert get_route is not None | |
| assert get_route.operation_id == "getResource" | |
| def test_http_get_method_path(parsed_http_methods_routes): | |
| """Test that GET method path is correctly extracted.""" | |
| get_route = next((r for r in parsed_http_methods_routes if r.method == "GET"), None) | |
| assert get_route is not None | |
| assert get_route.path == "/resource" | |
| def test_http_post_method_presence(parsed_http_methods_routes): | |
| """Test that POST method is correctly extracted.""" | |
| post_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "POST"), None | |
| ) | |
| assert post_route is not None | |
| assert post_route.operation_id == "createResource" | |
| def test_http_post_method_path(parsed_http_methods_routes): | |
| """Test that POST method path is correctly extracted.""" | |
| post_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "POST"), None | |
| ) | |
| assert post_route is not None | |
| assert post_route.path == "/resource" | |
| def test_http_put_method_presence(parsed_http_methods_routes): | |
| """Test that PUT method is correctly extracted.""" | |
| put_route = next((r for r in parsed_http_methods_routes if r.method == "PUT"), None) | |
| assert put_route is not None | |
| assert put_route.operation_id == "updateResource" | |
| def test_http_put_method_path(parsed_http_methods_routes): | |
| """Test that PUT method path is correctly extracted.""" | |
| put_route = next((r for r in parsed_http_methods_routes if r.method == "PUT"), None) | |
| assert put_route is not None | |
| assert put_route.path == "/resource" | |
| def test_http_delete_method_presence(parsed_http_methods_routes): | |
| """Test that DELETE method is correctly extracted.""" | |
| delete_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "DELETE"), None | |
| ) | |
| assert delete_route is not None | |
| assert delete_route.operation_id == "deleteResource" | |
| def test_http_delete_method_path(parsed_http_methods_routes): | |
| """Test that DELETE method path is correctly extracted.""" | |
| delete_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "DELETE"), None | |
| ) | |
| assert delete_route is not None | |
| assert delete_route.path == "/resource" | |
| def test_http_patch_method_presence(parsed_http_methods_routes): | |
| """Test that PATCH method is correctly extracted.""" | |
| patch_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "PATCH"), None | |
| ) | |
| assert patch_route is not None | |
| assert patch_route.operation_id == "patchResource" | |
| def test_http_patch_method_path(parsed_http_methods_routes): | |
| """Test that PATCH method path is correctly extracted.""" | |
| patch_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "PATCH"), None | |
| ) | |
| assert patch_route is not None | |
| assert patch_route.path == "/resource" | |
| def test_http_head_method_presence(parsed_http_methods_routes): | |
| """Test that HEAD method is correctly extracted.""" | |
| head_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "HEAD"), None | |
| ) | |
| assert head_route is not None | |
| assert head_route.operation_id == "headResource" | |
| def test_http_head_method_path(parsed_http_methods_routes): | |
| """Test that HEAD method path is correctly extracted.""" | |
| head_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "HEAD"), None | |
| ) | |
| assert head_route is not None | |
| assert head_route.path == "/resource" | |
| def test_http_options_method_presence(parsed_http_methods_routes): | |
| """Test that OPTIONS method is correctly extracted.""" | |
| options_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "OPTIONS"), None | |
| ) | |
| assert options_route is not None | |
| assert options_route.operation_id == "optionsResource" | |
| def test_http_options_method_path(parsed_http_methods_routes): | |
| """Test that OPTIONS method path is correctly extracted.""" | |
| options_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "OPTIONS"), None | |
| ) | |
| assert options_route is not None | |
| assert options_route.path == "/resource" | |
| def test_http_trace_method_presence(parsed_http_methods_routes): | |
| """Test that TRACE method is correctly extracted.""" | |
| trace_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "TRACE"), None | |
| ) | |
| assert trace_route is not None | |
| assert trace_route.operation_id == "traceResource" | |
| def test_http_trace_method_path(parsed_http_methods_routes): | |
| """Test that TRACE method path is correctly extracted.""" | |
| trace_route = next( | |
| (r for r in parsed_http_methods_routes if r.method == "TRACE"), None | |
| ) | |
| assert trace_route is not None | |
| assert trace_route.path == "/resource" | |
| def schema_with_external_reference() -> dict[str, Any]: | |
| """Fixture that returns a schema with external schema references like in issue #926.""" | |
| return { | |
| "openapi": "3.0.0", | |
| "info": {"title": "External Reference API", "version": "1.0.0"}, | |
| "paths": { | |
| "/products": { | |
| "post": { | |
| "summary": "Create a product", | |
| "operationId": "createProduct", | |
| "requestBody": { | |
| "required": True, | |
| "content": { | |
| "application/json": { | |
| "schema": { | |
| "type": "object", | |
| "properties": { | |
| "obj": { | |
| "$ref": "http://cyaninc.com/json-schemas/market-v1/product-constraints" | |
| } | |
| }, | |
| } | |
| } | |
| }, | |
| }, | |
| "responses": {"201": {"description": "Product created"}}, | |
| } | |
| } | |
| }, | |
| } | |
| # --- Tests for external schema reference handling --- # | |
| def test_external_reference_raises_clear_error(schema_with_external_reference): | |
| """Test that external schema references raise a clear, helpful error message.""" | |
| with pytest.raises(ValueError) as exc_info: | |
| parse_openapi_to_http_routes(schema_with_external_reference) | |
| error_message = str(exc_info.value) | |
| assert "External or non-local reference not supported" in error_message | |
| assert ( | |
| "http://cyaninc.com/json-schemas/market-v1/product-constraints" in error_message | |
| ) | |
| assert "FastMCP only supports local schema references" in error_message | |