fastmcp/tests/utilities/openapi/test_openapi_advanced.py
Jeremiah Lowin 6584750667 Fix external schema reference handling in OpenAPI parser
Previously, external schema references (URLs) in OpenAPI schemas were
silently passed through and only failed during JSON schema validation
with confusing "failed to match exactly one schema" errors.

This change:
- Detects external references in _replace_ref_with_defs() and raises clear error messages
- Updates exception handlers to propagate external reference errors while preserving other error handling
- Adds comprehensive test coverage for external reference detection
- Provides helpful error messages explaining that FastMCP only supports local schema references

Fixes #926

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 21:25:06 -04:00

665 lines
24 KiB
Python

"""Tests for advanced features of the OpenAPI utilities."""
from typing import Any
import pytest
from fastmcp.utilities.openapi import parse_openapi_to_http_routes
@pytest.fixture
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"},
},
}
}
},
}
},
},
}
@pytest.fixture
def parsed_complex_routes(complex_schema):
"""Return parsed routes from the complex schema."""
return parse_openapi_to_http_routes(complex_schema)
@pytest.fixture
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
}
@pytest.fixture
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
},
}
@pytest.fixture
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"}},
}
},
},
}
@pytest.fixture
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)
@pytest.fixture
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"}},
},
},
},
}
@pytest.fixture
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"
@pytest.fixture
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