"""Unit tests for OpenAPI parser.""" import pytest from fastmcp.utilities.openapi.parser import parse_openapi_to_http_routes class TestOpenAPIParser: """Test OpenAPI parsing functionality.""" def test_parse_basic_openapi_30(self, basic_openapi_30_spec): """Test parsing a basic OpenAPI 3.0 spec.""" routes = parse_openapi_to_http_routes(basic_openapi_30_spec) assert len(routes) == 1 route = routes[0] assert route.path == "/users/{id}" assert route.method == "GET" assert route.operation_id == "get_user" assert route.summary == "Get user by ID" # Check parameters assert len(route.parameters) == 1 param = route.parameters[0] assert param.name == "id" assert param.location == "path" assert param.required is True assert param.schema_["type"] == "integer" # Check pre-calculated fields assert hasattr(route, "flat_param_schema") assert hasattr(route, "parameter_map") assert route.flat_param_schema is not None assert route.parameter_map is not None def test_parse_basic_openapi_31(self, basic_openapi_31_spec): """Test parsing a basic OpenAPI 3.1 spec.""" routes = parse_openapi_to_http_routes(basic_openapi_31_spec) assert len(routes) == 1 route = routes[0] assert route.path == "/users/{id}" assert route.method == "GET" assert route.operation_id == "get_user" # Same structure should work for both 3.0 and 3.1 assert len(route.parameters) == 1 param = route.parameters[0] assert param.name == "id" assert param.location == "path" def test_parse_collision_spec(self, collision_spec): """Test parsing spec with parameter collisions.""" routes = parse_openapi_to_http_routes(collision_spec) assert len(routes) == 1 route = routes[0] assert route.operation_id == "update_user" # Should have path parameter path_params = [p for p in route.parameters if p.location == "path"] assert len(path_params) == 1 assert path_params[0].name == "id" # Should have request body assert route.request_body is not None assert route.request_body.required is True # Check that parameter map handles collisions assert route.parameter_map is not None # Should have entries for both path and body parameters assert len(route.parameter_map) >= 2 # At least path id and body fields def test_parse_deepobject_spec(self, deepobject_spec): """Test parsing spec with deepObject parameters.""" routes = parse_openapi_to_http_routes(deepobject_spec) assert len(routes) == 1 route = routes[0] assert route.operation_id == "search" # Should have deepObject parameter assert len(route.parameters) == 1 param = route.parameters[0] assert param.name == "filter" assert param.location == "query" assert param.style == "deepObject" assert param.explode is True assert param.schema_["type"] == "object" def test_parse_complex_spec(self, complex_spec): """Test parsing complex spec with multiple parameter types.""" routes = parse_openapi_to_http_routes(complex_spec) assert len(routes) == 1 route = routes[0] assert route.operation_id == "update_item" # Should have multiple parameters assert len(route.parameters) == 3 # Check parameter locations locations = {p.location for p in route.parameters} assert locations == {"path", "query", "header"} # Check specific parameters path_param = next(p for p in route.parameters if p.location == "path") assert path_param.name == "id" assert path_param.required is True query_param = next(p for p in route.parameters if p.location == "query") assert query_param.name == "version" assert query_param.required is False assert query_param.schema_.get("default") == 1 header_param = next(p for p in route.parameters if p.location == "header") assert header_param.name == "X-Client-Version" assert header_param.required is False # Check request body assert route.request_body is not None assert route.request_body.required is True def test_parse_empty_spec(self): """Test parsing spec with no paths.""" empty_spec = { "openapi": "3.0.0", "info": {"title": "Empty API", "version": "1.0.0"}, "paths": {}, } routes = parse_openapi_to_http_routes(empty_spec) assert len(routes) == 0 def test_parse_invalid_spec(self): """Test parsing invalid OpenAPI spec.""" invalid_spec = { "openapi": "3.0.0", # Missing required fields } with pytest.raises(ValueError, match="Invalid OpenAPI schema"): parse_openapi_to_http_routes(invalid_spec) def test_parse_spec_with_refs(self): """Test parsing spec with $ref references.""" spec_with_refs = { "openapi": "3.0.0", "info": {"title": "Ref Test API", "version": "1.0.0"}, "components": { "schemas": { "User": { "type": "object", "properties": { "id": {"type": "integer"}, "name": {"type": "string"}, }, } }, "parameters": { "UserId": { "name": "id", "in": "path", "required": True, "schema": {"type": "integer"}, } }, }, "paths": { "/users/{id}": { "get": { "operationId": "get_user", "parameters": [{"$ref": "#/components/parameters/UserId"}], "responses": { "200": { "description": "User", "content": { "application/json": { "schema": {"$ref": "#/components/schemas/User"} } }, } }, } } }, } routes = parse_openapi_to_http_routes(spec_with_refs) assert len(routes) == 1 route = routes[0] # Parameter should be resolved from $ref assert len(route.parameters) == 1 param = route.parameters[0] assert param.name == "id" assert param.location == "path" assert param.required is True def test_parse_simple_transitive_refs(self): """Test that A->B->C transitive references are preserved. When a request body references schema A, which references B, which references C: - A is expanded inline (expected optimization) - B and C MUST be included in $defs (the bug fix for #1372) """ spec = { "openapi": "3.0.0", "info": {"title": "Test", "version": "1.0.0"}, "components": { "schemas": { "SchemaA": { "type": "object", "properties": { "refToB": {"$ref": "#/components/schemas/SchemaB"} }, }, "SchemaB": { "type": "object", "properties": { "refToC": {"$ref": "#/components/schemas/SchemaC"} }, }, "SchemaC": { "type": "string", "enum": ["value1", "value2"], }, } }, "paths": { "/test": { "post": { "operationId": "test_op", "requestBody": { "content": { "application/json": { "schema": {"$ref": "#/components/schemas/SchemaA"} } } }, "responses": {"200": {"description": "OK"}}, } } }, } routes = parse_openapi_to_http_routes(spec) route = routes[0] # SchemaA is expanded inline, so it's NOT in request_schemas assert "SchemaA" not in route.request_schemas # But SchemaB and SchemaC MUST be there (transitive dependencies) assert "SchemaB" in route.request_schemas assert "SchemaC" in route.request_schemas # Same in the flat parameter schema assert "SchemaB" in route.flat_param_schema["$defs"] assert "SchemaC" in route.flat_param_schema["$defs"] def test_parse_tspicer_issue_1372(self): """Reproduce the exact bug from issue #1372 (tspicer's report). Issue: Profile -> {countryCode, AccountInfo} transitive refs were missing from $defs. """ spec = { "openapi": "3.0.1", "info": {"title": "Test", "version": "1.0.0"}, "components": { "schemas": { "Profile": { "type": "object", "properties": { "profileId": {"type": "integer"}, "countryCode": {"$ref": "#/components/schemas/countryCode"}, "accountInfo": {"$ref": "#/components/schemas/AccountInfo"}, }, }, "countryCode": { "type": "string", "enum": ["US", "UK", "CA", "AU"], }, "AccountInfo": { "type": "object", "properties": { "accountId": {"type": "string"}, "accountType": {"type": "string"}, }, }, } }, "paths": { "/profile": { "post": { "operationId": "create_profile", "requestBody": { "content": { "application/json": { "schema": {"$ref": "#/components/schemas/Profile"} } }, }, "responses": {"200": {"description": "OK"}}, } } }, } routes = parse_openapi_to_http_routes(spec) route = routes[0] # Profile is expanded inline, NOT in schema_defs assert "Profile" not in route.request_schemas # Bug fix: countryCode and AccountInfo MUST be in schema_defs assert "countryCode" in route.request_schemas # Was missing in #1372 assert "AccountInfo" in route.request_schemas # Was missing in #1372 # Same in flat parameter schema assert "countryCode" in route.flat_param_schema["$defs"] assert "AccountInfo" in route.flat_param_schema["$defs"] # Verify Profile's properties were inlined correctly props = route.flat_param_schema["properties"] assert "profileId" in props assert props["countryCode"]["$ref"] == "#/$defs/countryCode" assert props["accountInfo"]["$ref"] == "#/$defs/AccountInfo" def test_parameter_schema_extraction(self, complex_spec): """Test that parameter schemas are properly extracted.""" routes = parse_openapi_to_http_routes(complex_spec) route = routes[0] # Check that flat_param_schema contains all parameters flat_schema = route.flat_param_schema assert flat_schema["type"] == "object" assert "properties" in flat_schema properties = flat_schema["properties"] # Should contain path, query, and body parameters assert "id" in properties or any("id" in key for key in properties.keys()) assert "title" in properties # From request body # Check parameter mapping param_map = route.parameter_map assert len(param_map) > 0 # Each mapped parameter should have location and openapi_name for param_name, mapping in param_map.items(): assert "location" in mapping assert "openapi_name" in mapping assert mapping["location"] in ["path", "query", "header", "body"] class TestParameterLocationHandling: """Test parameter location conversion and handling.""" @pytest.mark.parametrize( "location_str,expected", [ ("path", "path"), ("query", "query"), ("header", "header"), ("cookie", "cookie"), ("unknown", "query"), # Should default to query ], ) def test_parameter_location_conversion(self, location_str, expected): """Test parameter location string conversion.""" # Create a simple spec with the parameter location spec = { "openapi": "3.0.0", "info": {"title": "Location Test", "version": "1.0.0"}, "paths": { "/test": { "get": { "operationId": "test_op", "parameters": [ { "name": "test_param", "in": location_str, "schema": {"type": "string"}, } ], "responses": {"200": {"description": "OK"}}, } } }, } if location_str == "unknown": # Should raise validation error for unknown location with pytest.raises(ValueError, match="Invalid OpenAPI schema"): parse_openapi_to_http_routes(spec) else: routes = parse_openapi_to_http_routes(spec) route = routes[0] param = route.parameters[0] assert param.location == expected class TestErrorHandling: """Test error handling in parser.""" def test_external_ref_error(self): """Test that external references are handled gracefully.""" spec_with_external_ref = { "openapi": "3.0.0", "info": {"title": "External Ref Test", "version": "1.0.0"}, "paths": { "/test": { "get": { "operationId": "test_op", "parameters": [ { "$ref": "external-file.yaml#/components/parameters/ExternalParam" } ], "responses": {"200": {"description": "OK"}}, } } }, } # Should not crash but skip the invalid parameter routes = parse_openapi_to_http_routes(spec_with_external_ref) assert len(routes) == 1 assert ( len(routes[0].parameters) == 0 ) # External ref parameter should be skipped def test_broken_ref_error(self): """Test that broken internal references are handled gracefully.""" spec_with_broken_ref = { "openapi": "3.0.0", "info": {"title": "Broken Ref Test", "version": "1.0.0"}, "paths": { "/test": { "get": { "operationId": "test_op", "parameters": [ {"$ref": "#/components/parameters/NonExistentParam"} ], "responses": {"200": {"description": "OK"}}, } } }, } # Should handle broken refs gracefully and continue parsing routes = parse_openapi_to_http_routes(spec_with_broken_ref) # May have empty routes or skip the broken operation assert isinstance(routes, list)