Add openapi parsing utilities

This commit is contained in:
Jeremiah Lowin 2025-04-08 21:11:52 -04:00
commit a683abb8a3
7 changed files with 2211 additions and 0 deletions

View file

@ -0,0 +1 @@
"""Tests for utilities in the fastmcp package."""

View file

@ -0,0 +1 @@
"""Tests for the OpenAPI utilities."""

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,709 @@
"""Tests for the OpenAPI parsing utilities."""
from typing import Any, Dict
import pytest
from fastapi import Body, FastAPI, Path, Query
from pydantic import BaseModel, Field
from fastmcp.utilities.openapi import parse_openapi_to_http_routes
# --- Test Data: Static OpenAPI Schema Dictionaries --- #
@pytest.fixture
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"}
}
},
}
},
},
}
@pytest.fixture
def parsed_petstore_routes(petstore_schema):
"""Return parsed routes from the PetStore schema."""
return parse_openapi_to_http_routes(petstore_schema)
@pytest.fixture
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"}},
},
},
},
}
@pytest.fixture
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)
@pytest.fixture
def fastapi_app() -> FastAPI:
"""Fixture that returns a FastAPI app with various types of endpoints."""
app = FastAPI(title="Test API", version="1.0.0")
@app.get("/items/", operation_id="list_items")
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)
]
@app.post("/items/", operation_id="create_item")
async def create_item(item: Item):
"""Create a new item."""
return item
@app.get("/items/{item_id}", operation_id="get_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}
@app.put("/items/{item_id}", operation_id="update_item")
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()}
@app.delete("/items/{item_id}", operation_id="delete_item")
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}
@app.get("/items/{item_id}/tags/{tag_id}", operation_id="get_item_tag")
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}
@app.post("/upload/", operation_id="upload_file")
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
@pytest.fixture
def fastapi_openapi_schema(fastapi_app) -> Dict[str, Any]:
"""Fixture that returns the OpenAPI schema of the FastAPI app."""
return fastapi_app.openapi()
@pytest.fixture
def parsed_fastapi_routes(fastapi_openapi_schema):
"""Return parsed routes from a FastAPI OpenAPI schema."""
return parse_openapi_to_http_routes(fastapi_openapi_schema)
@pytest.fixture
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
}
# --- 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"]
# --- 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

View file

@ -0,0 +1,594 @@
"""Tests for advanced features of the OpenAPI utilities."""
from typing import Any, Dict
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_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"

View file

@ -0,0 +1,437 @@
"""Tests for FastAPI integration with the OpenAPI utilities."""
from typing import Any, Dict
import pytest
from fastapi import FastAPI
from fastmcp.utilities.openapi import parse_openapi_to_http_routes
@pytest.fixture
def fastapi_server() -> FastAPI:
"""Fixture that returns a FastAPI app for live OpenAPI schema testing."""
from enum import Enum
from typing import List, Optional
from fastapi import Body, Depends, Header, HTTPException, Path, Query
from pydantic import BaseModel, Field
class ItemStatus(str, Enum):
available = "available"
pending = "pending"
sold = "sold"
class Tag(BaseModel):
id: int
name: str
class Item(BaseModel):
"""Example pydantic model for testing OpenAPI schema generation."""
name: str
description: Optional[str] = None
price: float
tax: Optional[float] = None
tags: List[str] = Field(default_factory=list)
status: ItemStatus = ItemStatus.available
dimensions: Optional[Dict[str, float]] = None
# Create a FastAPI app with comprehensive features
app = FastAPI(
title="Comprehensive Test API",
description="A test API with various OpenAPI features",
version="1.0.0",
)
def get_token_header(
x_token: str = Header(..., description="Authentication token"),
):
"""Example dependency function for header validation."""
if x_token != "fake-super-secret-token":
raise HTTPException(status_code=400, detail="X-Token header invalid")
return x_token
TokenDep = Depends(get_token_header)
@app.get(
"/items/",
operation_id="list_items",
summary="List all items",
description="Get a list of all items with optional filtering",
tags=["items"],
)
async def list_items(
skip: int = Query(0, description="Number of items to skip"),
limit: int = Query(10, description="Max number of items to return"),
status: Optional[ItemStatus] = Query(
None, description="Filter items by status"
),
):
"""List all items with pagination and optional status filtering."""
fake_items = [
{"name": f"Item {i}", "price": float(i)} for i in range(skip, skip + limit)
]
if status:
fake_items = [item for item in fake_items if item.get("status") == status]
return fake_items
@app.post(
"/items/",
operation_id="create_item",
summary="Create a new item",
tags=["items"],
status_code=201,
)
async def create_item(
item: Item = Body(..., description="Item to create"),
x_token: str = TokenDep,
):
"""Create a new item (requires authentication)."""
return item
@app.get(
"/items/{item_id}",
operation_id="get_item",
summary="Get a specific item by ID",
tags=["items"],
)
async def get_item(
item_id: int = Path(..., description="The ID of the item to retrieve"),
include_tax: bool = Query(
False, description="Whether to include tax information"
),
):
"""Get details about a specific item."""
item = {
"id": item_id,
"name": f"Item {item_id}",
"price": float(item_id) * 10.0,
}
if include_tax:
item["tax"] = item["price"] * 0.2
return item
@app.put(
"/items/{item_id}",
operation_id="update_item",
summary="Update an existing item",
tags=["items"],
)
async def update_item(
item_id: int = Path(..., description="The ID of the item to update"),
item: Item = Body(..., description="Updated item data"),
x_token: str = TokenDep,
):
"""Update an existing item (requires authentication)."""
return {"item_id": item_id, **item.model_dump()}
@app.delete(
"/items/{item_id}",
operation_id="delete_item",
summary="Delete an item",
tags=["items"],
)
async def delete_item(
item_id: int = Path(..., description="The ID of the item to delete"),
x_token: str = TokenDep,
):
"""Delete an item (requires authentication)."""
return {"item_id": item_id, "deleted": True}
@app.patch(
"/items/{item_id}/tags",
operation_id="update_item_tags",
summary="Update item tags",
tags=["items", "tags"],
)
async def update_item_tags(
item_id: int = Path(..., description="The ID of the item"),
tags: List[str] = Body(..., description="Updated tags"),
):
"""Update just the tags of an item."""
return {"item_id": item_id, "tags": tags}
@app.get(
"/items/{item_id}/tags/{tag_id}",
operation_id="get_item_tag",
summary="Get a specific tag for an item",
tags=["items", "tags"],
)
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}
@app.post(
"/upload/",
operation_id="upload_file",
summary="Upload a file",
tags=["files"],
)
async def upload_file(
file_name: str = Query(..., description="Name of the file"),
content_type: str = Query(..., description="Content type of the file"),
):
"""Upload a file (dummy endpoint for testing query params)."""
return {
"file_name": file_name,
"content_type": content_type,
"status": "uploaded",
}
# Add a callback route for testing complex documentation
@app.post(
"/webhook",
operation_id="register_webhook",
summary="Register a webhook",
tags=["webhooks"],
callbacks={ # type: ignore
"itemProcessed": {
"{$request.body.callbackUrl}": {
"post": {
"summary": "Callback for when an item is processed",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"item_id": {"type": "integer"},
"status": {"type": "string"},
"timestamp": {
"type": "string",
"format": "date-time",
},
},
}
}
},
},
"responses": {
"200": {"description": "Webhook processed successfully"}
},
}
}
}
},
)
async def register_webhook(
callback_url: str = Body(
..., embed=True, description="URL to call when processing completes"
),
):
"""Register a webhook for processing notifications."""
return {"registered": True, "callback_url": callback_url}
return app
@pytest.fixture
def fastapi_openapi_schema(fastapi_server) -> Dict[str, Any]:
"""Fixture that returns the OpenAPI schema from a live FastAPI server."""
return fastapi_server.openapi()
@pytest.fixture
def parsed_routes(fastapi_openapi_schema):
"""Return parsed routes from a FastAPI OpenAPI schema."""
return parse_openapi_to_http_routes(fastapi_openapi_schema)
@pytest.fixture
def route_map(parsed_routes):
"""Return a dictionary of routes by operation ID."""
return {r.operation_id: r for r in parsed_routes if r.operation_id is not None}
def test_parse_fastapi_schema_route_count(parsed_routes):
"""Test that all routes are parsed from the FastAPI schema."""
assert len(parsed_routes) == 9 # 8 endpoints + 1 callback
def test_parse_fastapi_schema_operation_ids(route_map):
"""Test that all expected operation IDs are present in the parsed schema."""
expected_operations = [
"list_items",
"create_item",
"get_item",
"update_item",
"delete_item",
"update_item_tags",
"get_item_tag",
"upload_file",
"register_webhook",
]
for op_id in expected_operations:
assert op_id in route_map, f"Operation ID '{op_id}' not found in parsed routes"
def test_path_parameter_parsing(route_map):
"""Test that path parameters are correctly parsed."""
get_item = route_map["get_item"]
path_params = [p for p in get_item.parameters if p.location == "path"]
assert len(path_params) == 1
assert path_params[0].name == "item_id"
assert path_params[0].required is True
def test_query_parameter_parsing(route_map):
"""Test that query parameters are correctly parsed."""
list_items = route_map["list_items"]
query_params = [p for p in list_items.parameters if p.location == "query"]
assert len(query_params) == 3 # skip, limit, status
param_names = [p.name for p in query_params]
assert "skip" in param_names
assert "limit" in param_names
assert "status" in param_names
def test_header_parameter_parsing(route_map):
"""Test that header parameters from dependencies are correctly parsed."""
create_item = route_map["create_item"]
header_params = [p for p in create_item.parameters if p.location == "header"]
assert len(header_params) == 1
assert header_params[0].name == "x-token"
assert header_params[0].required is True
def test_request_body_content_type(route_map):
"""Test that request body content types are correctly parsed."""
create_item = route_map["create_item"]
assert create_item.request_body is not None
assert "application/json" in create_item.request_body.content_schema
def test_request_body_properties(route_map):
"""Test that request body properties are correctly parsed."""
create_item = route_map["create_item"]
json_schema = create_item.request_body.content_schema["application/json"]
properties = json_schema.get("properties", {})
assert "name" in properties
assert "price" in properties
assert "description" in properties
assert "tags" in properties
assert "status" in properties
def test_request_body_status_schema(route_map):
"""Test that the status schema in request body is correctly handled."""
create_item = route_map["create_item"]
json_schema = create_item.request_body.content_schema["application/json"]
properties = json_schema.get("properties", {})
status_schema = properties.get("status", {})
# FastAPI may represent enums as references or directly include enum values
assert "$ref" in status_schema or "enum" in status_schema
def test_route_with_items_tag(parsed_routes):
"""Test that routes with 'items' tag are correctly parsed."""
item_routes = [r for r in parsed_routes if "items" in r.tags]
assert len(item_routes) >= 6 # At least 6 endpoints with "items" tag
def test_routes_with_multiple_tags(parsed_routes):
"""Test that routes with multiple tags are correctly parsed."""
multi_tag_routes = [r for r in parsed_routes if len(r.tags) > 1]
assert len(multi_tag_routes) >= 2 # At least 2 endpoints with multiple tags
def test_specific_route_tags(route_map):
"""Test that specific routes have the expected tags."""
assert "items" in route_map["list_items"].tags
assert "items" in route_map["update_item_tags"].tags
assert "tags" in route_map["update_item_tags"].tags
assert "webhooks" in route_map["register_webhook"].tags
def test_operation_summary(route_map):
"""Test that operation summary is correctly parsed."""
list_items = route_map["list_items"]
assert list_items.summary == "List all items"
def test_operation_description(route_map):
"""Test that operation description is correctly parsed."""
list_items = route_map["list_items"]
assert list_items.description is not None
assert "optional filtering" in list_items.description
def test_path_with_route_parameters(route_map):
"""Test that paths with route parameters are correctly parsed."""
get_item = route_map["get_item"]
assert get_item.path == "/items/{item_id}"
def test_complex_nested_paths(route_map):
"""Test that complex nested paths are correctly parsed."""
get_item_tag = route_map["get_item_tag"]
assert get_item_tag.path == "/items/{item_id}/tags/{tag_id}"
def test_http_methods(route_map):
"""Test that HTTP methods are correctly parsed."""
assert route_map["list_items"].method == "GET"
assert route_map["create_item"].method == "POST"
assert route_map["update_item"].method == "PUT"
assert route_map["delete_item"].method == "DELETE"
assert route_map["update_item_tags"].method == "PATCH"
def test_item_schema_properties(route_map):
"""Test that Item schema properties are correctly resolved."""
create_item = route_map["create_item"]
json_schema = create_item.request_body.content_schema["application/json"]
properties = json_schema.get("properties", {})
assert "name" in properties
assert properties["name"]["type"] == "string"
assert "price" in properties
assert properties["price"]["type"] == "number"
def test_webhook_endpoint(route_map):
"""Test parsing of webhook registration endpoint."""
webhook = route_map["register_webhook"]
assert webhook.method == "POST"
assert webhook.path == "/webhook"
def test_webhook_request_body(route_map):
"""Test that webhook request body is correctly parsed."""
webhook = route_map["register_webhook"]
assert webhook.request_body is not None
assert "application/json" in webhook.request_body.content_schema
json_schema = webhook.request_body.content_schema["application/json"]
assert "callback_url" in json_schema.get("properties", {})
def test_token_dependency_handling(route_map):
"""Test that token dependencies are correctly handled in parsed endpoints."""
token_endpoints = ["create_item", "update_item", "delete_item"]
for op_id in token_endpoints:
route = route_map[op_id]
header_params = [p for p in route.parameters if p.location == "header"]
token_headers = [p for p in header_params if p.name == "x-token"]
assert len(token_headers) == 1, f"Expected x-token header in {op_id}"
assert token_headers[0].required is True