mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 02:10:38 +02:00
Add openapi parsing utilities
This commit is contained in:
parent
8ffe9df065
commit
a683abb8a3
7 changed files with 2211 additions and 0 deletions
1
tests/utilities/__init__.py
Normal file
1
tests/utilities/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests for utilities in the fastmcp package."""
|
||||
1
tests/utilities/openapi/__init__.py
Normal file
1
tests/utilities/openapi/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the OpenAPI utilities."""
|
||||
1
tests/utilities/openapi/conftest.py
Normal file
1
tests/utilities/openapi/conftest.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
709
tests/utilities/openapi/test_openapi.py
Normal file
709
tests/utilities/openapi/test_openapi.py
Normal 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
|
||||
594
tests/utilities/openapi/test_openapi_advanced.py
Normal file
594
tests/utilities/openapi/test_openapi_advanced.py
Normal 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"
|
||||
437
tests/utilities/openapi/test_openapi_fastapi.py
Normal file
437
tests/utilities/openapi/test_openapi_fastapi.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue