You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

517 lines
19 KiB

from typing import Annotated
from fastapi import Body, Cookie, FastAPI, Header, Path, Query
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
data: str
@app.post("/examples/")
def examples(
item: Item = Body(
examples=[
{"data": "Data in Body examples, example1"},
],
openapi_examples={
"Example One": {
"summary": "Example One Summary",
"description": "Example One Description",
"value": {"data": "Data in Body examples, example1"},
},
"Example Two": {
"value": {"data": "Data in Body examples, example2"},
},
},
),
):
return item
@app.get("/path_examples/{item_id}")
def path_examples(
item_id: str = Path(
examples=[
"json_schema_item_1",
"json_schema_item_2",
],
openapi_examples={
"Path One": {
"summary": "Path One Summary",
"description": "Path One Description",
"value": "item_1",
},
"Path Two": {
"value": "item_2",
},
},
),
):
return item_id
@app.get("/query_examples/")
def query_examples(
data: str | None = Query(
default=None,
examples=[
"json_schema_query1",
"json_schema_query2",
],
openapi_examples={
"Query One": {
"summary": "Query One Summary",
"description": "Query One Description",
"value": "query1",
},
"Query Two": {
"value": "query2",
},
},
),
):
return data
@app.get("/header_examples/")
def header_examples(
data: str | None = Header(
default=None,
examples=[
"json_schema_header1",
"json_schema_header2",
],
openapi_examples={
"Header One": {
"summary": "Header One Summary",
"description": "Header One Description",
"value": "header1",
},
"Header Two": {
"value": "header2",
},
},
),
):
return data
@app.get("/cookie_examples/")
def cookie_examples(
data: str | None = Cookie(
default=None,
examples=["json_schema_cookie1", "json_schema_cookie2"],
openapi_examples={
"Cookie One": {
"summary": "Cookie One Summary",
"description": "Cookie One Description",
"value": "cookie1",
},
"Cookie Two": {
"value": "cookie2",
},
},
),
):
return data
client = TestClient(app)
def test_call_api():
response = client.post("/examples/", json={"data": "example1"})
assert response.status_code == 200, response.text
response = client.get("/path_examples/foo")
assert response.status_code == 200, response.text
response = client.get("/query_examples/")
assert response.status_code == 200, response.text
response = client.get("/header_examples/")
assert response.status_code == 200, response.text
response = client.get("/cookie_examples/")
assert response.status_code == 200, response.text
def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
assert response.json() == snapshot(
{
"openapi": "3.1.0",
"info": {"title": "FastAPI", "version": "0.1.0"},
"paths": {
"/examples/": {
"post": {
"summary": "Examples",
"operationId": "examples_examples__post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Item",
"examples": [
{"data": "Data in Body examples, example1"}
],
},
"examples": {
"Example One": {
"summary": "Example One Summary",
"description": "Example One Description",
"value": {
"data": "Data in Body examples, example1"
},
},
"Example Two": {
"value": {
"data": "Data in Body examples, example2"
}
},
},
}
},
"required": True,
},
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/path_examples/{item_id}": {
"get": {
"summary": "Path Examples",
"operationId": "path_examples_path_examples__item_id__get",
"parameters": [
{
"name": "item_id",
"in": "path",
"required": True,
"schema": {
"type": "string",
"examples": [
"json_schema_item_1",
"json_schema_item_2",
],
"title": "Item Id",
},
"examples": {
"Path One": {
"summary": "Path One Summary",
"description": "Path One Description",
"value": "item_1",
},
"Path Two": {"value": "item_2"},
},
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/query_examples/": {
"get": {
"summary": "Query Examples",
"operationId": "query_examples_query_examples__get",
"parameters": [
{
"name": "data",
"in": "query",
"required": False,
"schema": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"examples": [
"json_schema_query1",
"json_schema_query2",
],
"title": "Data",
},
"examples": {
"Query One": {
"summary": "Query One Summary",
"description": "Query One Description",
"value": "query1",
},
"Query Two": {"value": "query2"},
},
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/header_examples/": {
"get": {
"summary": "Header Examples",
"operationId": "header_examples_header_examples__get",
"parameters": [
{
"name": "data",
"in": "header",
"required": False,
"schema": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"examples": [
"json_schema_header1",
"json_schema_header2",
],
"title": "Data",
},
"examples": {
"Header One": {
"summary": "Header One Summary",
"description": "Header One Description",
"value": "header1",
},
"Header Two": {"value": "header2"},
},
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/cookie_examples/": {
"get": {
"summary": "Cookie Examples",
"operationId": "cookie_examples_cookie_examples__get",
"parameters": [
{
"name": "data",
"in": "cookie",
"required": False,
"schema": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"examples": [
"json_schema_cookie1",
"json_schema_cookie2",
],
"title": "Data",
},
"examples": {
"Cookie One": {
"summary": "Cookie One Summary",
"description": "Cookie One Description",
"value": "cookie1",
},
"Cookie Two": {"value": "cookie2"},
},
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
},
"components": {
"schemas": {
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail",
}
},
"type": "object",
"title": "HTTPValidationError",
},
"Item": {
"properties": {"data": {"type": "string", "title": "Data"}},
"type": "object",
"required": ["data"],
"title": "Item",
},
"ValidationError": {
"properties": {
"ctx": {"title": "Context", "type": "object"},
"input": {"title": "Input"},
"loc": {
"items": {
"anyOf": [{"type": "string"}, {"type": "integer"}]
},
"type": "array",
"title": "Location",
},
"msg": {"type": "string", "title": "Message"},
"type": {"type": "string", "title": "Error Type"},
},
"type": "object",
"required": ["loc", "msg", "type"],
"title": "ValidationError",
},
}
},
}
)
# Tests for the "null values discarded from OpenAPI examples" bug
# (see github discussions #8401, #12048 and issue #5559). The historical
# `exclude_none=True` pass at the end of `get_openapi` was stripping `null`
# values from inside user-provided `example` / `examples`, where `null` may
# be a legitimate documented value.
_null_app = FastAPI()
@_null_app.get(
"/r1",
responses={
200: {
"content": {
"application/json": {"example": {"absent": None, "present": "value"}}
}
}
},
)
def _r1() -> dict:
return {}
@_null_app.get(
"/r2",
responses={
200: {
"content": {
"application/json": {
"examples": {
"with_null": {
"summary": "has null",
"value": {"a": None, "b": 1},
},
}
}
}
}
},
)
def _r2() -> dict:
return {}
@_null_app.get("/r3")
def _r3(
q: Annotated[
str | None,
Query(examples={"nil": {"value": None}, "val": {"value": "x"}}),
] = None,
) -> dict:
return {}
def test_null_preserved_in_response_example():
schema = _null_app.openapi()
example = schema["paths"]["/r1"]["get"]["responses"]["200"]["content"][
"application/json"
]["example"]
assert example == {"absent": None, "present": "value"}
def test_null_preserved_in_response_examples_plural():
schema = _null_app.openapi()
examples = schema["paths"]["/r2"]["get"]["responses"]["200"]["content"][
"application/json"
]["examples"]
assert examples["with_null"]["value"] == {"a": None, "b": 1}
def test_null_preserved_in_parameter_examples():
schema = _null_app.openapi()
param = schema["paths"]["/r3"]["get"]["parameters"][0]
examples = param["schema"]["examples"]
assert examples["nil"]["value"] is None
assert examples["val"]["value"] == "x"
def test_unrelated_none_fields_still_stripped():
"""Regression: only `null`s inside `example` / `examples` are preserved.
Other internal `None` defaults (e.g. unset `summary`, `description`,
Pydantic-default `example: None` on parameter dicts) must still be
stripped, as the historical behavior."""
schema = _null_app.openapi()
# No parameter should carry a stray `example: null` from internal defaults.
for path_item in schema["paths"].values():
for op in path_item.values():
if not isinstance(op, dict):
continue
for parameter in op.get("parameters", []):
assert "example" not in parameter or parameter["example"] is not None