pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
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
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
|
|
|