]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
✨ Add support for custom `generate_unique_id_function` and docs for generating client...
authorSebastián Ramírez <tiangolo@gmail.com>
Fri, 4 Mar 2022 22:02:18 +0000 (14:02 -0800)
committerGitHub <noreply@github.com>
Fri, 4 Mar 2022 22:02:18 +0000 (23:02 +0100)
25 files changed:
docs/en/docs/advanced/generate-clients.md [new file with mode: 0644]
docs/en/docs/img/tutorial/generate-clients/image01.png [new file with mode: 0644]
docs/en/docs/img/tutorial/generate-clients/image02.png [new file with mode: 0644]
docs/en/docs/img/tutorial/generate-clients/image03.png [new file with mode: 0644]
docs/en/docs/img/tutorial/generate-clients/image04.png [new file with mode: 0644]
docs/en/docs/img/tutorial/generate-clients/image05.png [new file with mode: 0644]
docs/en/docs/img/tutorial/generate-clients/image06.png [new file with mode: 0644]
docs/en/docs/img/tutorial/generate-clients/image07.png [new file with mode: 0644]
docs/en/docs/img/tutorial/generate-clients/image08.png [new file with mode: 0644]
docs/en/mkdocs.yml
docs_src/generate_clients/tutorial001.py [new file with mode: 0644]
docs_src/generate_clients/tutorial001_py39.py [new file with mode: 0644]
docs_src/generate_clients/tutorial002.py [new file with mode: 0644]
docs_src/generate_clients/tutorial002_py39.py [new file with mode: 0644]
docs_src/generate_clients/tutorial003.py [new file with mode: 0644]
docs_src/generate_clients/tutorial003_py39.py [new file with mode: 0644]
docs_src/generate_clients/tutorial004.py [new file with mode: 0644]
fastapi/applications.py
fastapi/openapi/utils.py
fastapi/routing.py
fastapi/utils.py
tests/test_generate_unique_id_function.py [new file with mode: 0644]
tests/test_include_router_defaults_overrides.py
tests/test_tutorial/test_generate_clients/__init__.py [new file with mode: 0644]
tests/test_tutorial/test_generate_clients/test_tutorial003.py [new file with mode: 0644]

diff --git a/docs/en/docs/advanced/generate-clients.md b/docs/en/docs/advanced/generate-clients.md
new file mode 100644 (file)
index 0000000..f31a248
--- /dev/null
@@ -0,0 +1,267 @@
+# Generate Clients
+
+As **FastAPI** is based on the OpenAPI specification, you get automatic compatibility with many tools, including the automatic API docs (provided by Swagger UI).
+
+One particular advantage that is not necessarily obvious is that you can **generate clients** (sometimes called <abbr title="Software Development Kits">**SDKs**</abbr> ) for your API, for many different **programming languages**.
+
+## OpenAPI Client Generators
+
+There are many tools to generate clients from **OpenAPI**.
+
+A common tool is <a href="https://openapi-generator.tech/" class="external-link" target="_blank">OpenAPI Generator</a>.
+
+If you are building a **frontend**, a very interesting alternative is <a href="https://github.com/ferdikoomen/openapi-typescript-codegen" class="external-link" target="_blank">openapi-typescript-codegen</a>.
+
+## Generate a TypeScript Frontend Client
+
+Let's start with a simple FastAPI application:
+
+=== "Python 3.6 and above"
+
+    ```Python hl_lines="9-11  14-15  18  19  23"
+    {!> ../../../docs_src/generate_clients/tutorial001.py!}
+    ```
+
+=== "Python 3.9 and above"
+
+    ```Python hl_lines="7-9  12-13  16-17  21"
+    {!> ../../../docs_src/generate_clients/tutorial001_py39.py!}
+    ```
+
+Notice that the *path operations* define the models they use for request payload and response payload, using the models `Item` and `ResponseMessage`.
+
+### API Docs
+
+If you go to the API docs, you will see that it has the **schemas** for the data to be sent in requests and received in responses:
+
+<img src="/img/tutorial/generate-clients/image01.png">
+
+You can see those schemas because they were declared with the models in the app.
+
+That information is available in the app's **OpenAPI schema**, and then shown in the API docs (by Swagger UI).
+
+And that same information from the models that is included in OpenAPI is what can be used to **generate the client code**.
+
+### Generate a TypeScript Client
+
+Now that we have the app with the models, we can generate the client code for the frontend.
+
+#### Install `openapi-typescript-codegen`
+
+You can install `openapi-typescript-codegen` in your frontend code with:
+
+<div class="termy">
+
+```console
+$ npm install openapi-typescript-codegen --save-dev
+
+---> 100%
+```
+
+</div>
+
+#### Generate Client Code
+
+To generate the client code you can use the command line application `openapi` that would now be installed.
+
+Because it is installed in the local project, you probably wouldn't be able to call that command directly, but you would put it on your `package.json` file.
+
+It could look like this:
+
+```JSON  hl_lines="7"
+{
+  "name": "frontend-app",
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
+  "scripts": {
+    "generate-client": "openapi --input http://localhost:8000/openapi.json --output ./src/client --client axios"
+  },
+  "author": "",
+  "license": "",
+  "devDependencies": {
+    "openapi-typescript-codegen": "^0.20.1",
+    "typescript": "^4.6.2"
+  }
+}
+```
+
+After having that NPM `generate-client` script there, you can run it with:
+
+<div class="termy">
+
+```console
+$ npm run generate-client
+
+frontend-app@1.0.0 generate-client /home/user/code/frontend-app
+> openapi --input http://localhost:8000/openapi.json --output ./src/client --client axios
+```
+
+</div>
+
+That command will generate code in `./src/client` and will use `axios` (the frontend HTTP library) internally.
+
+### Try Out the Client Code
+
+Now you can import and use the client code, it could look like this, notice that you get autocompletion for the methods:
+
+<img src="/img/tutorial/generate-clients/image02.png">
+
+You will also get autocompletion for the payload to send:
+
+<img src="/img/tutorial/generate-clients/image03.png">
+
+!!! tip
+    Notice the autocompletion for `name` and `price`, that was defined in the FastAPI application, in the `Item` model.
+
+You will have inline errors for the data that you send:
+
+<img src="/img/tutorial/generate-clients/image04.png">
+
+The response object will also have autocompletion:
+
+<img src="/img/tutorial/generate-clients/image05.png">
+
+## FastAPI App with Tags
+
+In many cases your FastAPI app will be bigger, and you will probably use tags to separate different groups of *path operations*.
+
+For example, you could have a section for **items** and another section for **users**, and they could be separated by tags:
+
+
+=== "Python 3.6 and above"
+
+    ```Python hl_lines="23  28  36"
+    {!> ../../../docs_src/generate_clients/tutorial002.py!}
+    ```
+
+=== "Python 3.9 and above"
+
+    ```Python hl_lines="21  26  34"
+    {!> ../../../docs_src/generate_clients/tutorial002_py39.py!}
+    ```
+
+### Generate a TypeScript Client with Tags
+
+If you generate a client for a FastAPI app using tags, it will normally also separate the client code based on the tags.
+
+This way you will be able to have things ordered and grouped correctly for the client code:
+
+<img src="/img/tutorial/generate-clients/image06.png">
+
+In this case you have:
+
+* `ItemsService`
+* `UsersService`
+
+### Client Method Names
+
+Right now the generated method names like `createItemItemsPost` don't look very clean:
+
+```TypeScript
+ItemsService.createItemItemsPost({name: "Plumbus", price: 5})
+```
+
+...that's because the client generator uses the OpenAPI internal **operation ID** for each *path operation*.
+
+OpenAPI requires that each operation ID is unique across all the *path operations*, so FastAPI uses the **function name**, the **path**, and the **HTTP method/operation** to generate that operation ID, because that way it can make sure that the operation IDs are unique.
+
+But I'll show you how to improve that next. 🤓
+
+## Custom Operation IDs and Better Method Names
+
+You can **modify** the way these operation IDs are **generated** to make them simpler and have **simpler method names** in the clients.
+
+In this case you will have to ensure that each operation ID is **unique** in some other way.
+
+For example, you could make sure that each *path operation* has a tag, and then generate the operation ID based on the **tag** and the *path operation* **name** (the function name).
+
+### Custom Generate Unique ID Function
+
+FastAPI uses a **unique ID** for each *path operation*, it is used for the **operation ID** and also for the names of any needed custom models, for requests or responses.
+
+You can customize that function. It takes an `APIRoute` and outputs a string.
+
+For example, here it is using the first tag (you will probably have only one tag) and the *path operation* name (the function name).
+
+You can then pass that custom function to **FastAPI** as the `generate_unique_id_function` parameter:
+
+=== "Python 3.6 and above"
+
+    ```Python hl_lines="8-9  12"
+    {!> ../../../docs_src/generate_clients/tutorial003.py!}
+    ```
+
+=== "Python 3.9 and above"
+
+    ```Python hl_lines="6-7  10"
+    {!> ../../../docs_src/generate_clients/tutorial003_py39.py!}
+    ```
+
+### Generate a TypeScript Client with Custom Operation IDs
+
+Now if you generate the client again, you will see that it has the improved method names:
+
+<img src="/img/tutorial/generate-clients/image07.png">
+
+As you see, the method names now have the tag and then the function name, now they don't include information from the URL path and the HTTP operation.
+
+### Preprocess the OpenAPI Specification for the Client Generator
+
+The generated code still has some **duplicated information**.
+
+We already know that this method is related to the **items** because that word is in the `ItemsService` (taken from the tag), but we still have the tag name prefixed in the method name too. 😕
+
+We will probably still want to keep it for OpenAPI in general, as that will ensure that the operation IDs are **unique**.
+
+But for the generated client we could **modify** the OpenAPI operation IDs right before generating the clients, just to make those method names nicer and **cleaner**.
+
+We could download the OpenAPI JSON to a file `openapi.json` and then we could **remove that prefixed tag** with a script like this:
+
+```Python
+{!../../../docs_src/generate_clients/tutorial004.py!}
+```
+
+With that, the operation IDs would be renamed from things like `items-get_items` to just `get_items`, that way the client generator can generate simpler method names.
+
+### Generate a TypeScript Client with the Preprocessed OpenAPI
+
+Now as the end result is in a file `openapi.json`, you would modify the `package.json` to use that local file, for example:
+
+```JSON  hl_lines="7"
+{
+  "name": "frontend-app",
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
+  "scripts": {
+    "generate-client": "openapi --input ./openapi.json --output ./src/client --client axios"
+  },
+  "author": "",
+  "license": "",
+  "devDependencies": {
+    "openapi-typescript-codegen": "^0.20.1",
+    "typescript": "^4.6.2"
+  }
+}
+```
+
+After generating the new client, you would now have **clean method names**, with all the **autocompletion**, **inline errors**, etc:
+
+<img src="/img/tutorial/generate-clients/image08.png">
+
+## Benefits
+
+When using the automatically generated clients you would **autocompletion** for:
+
+* Methods.
+* Request payloads in the body, query parameters, etc.
+* Response payloads.
+
+You would also have **inline errors** for everything.
+
+And whenever you update the backend code, and **regenerate** the frontend, it would have any new *path operations* available as methods, the old ones removed, and any other change would be reflected on the generated code. 🤓
+
+This also means that if something changed it will be **reflected** on the client code automatically. And if you **build** the client it will error out if you have any **mismatch** in the data used.
+
+So, you would **detect many errors** very early in the development cycle instead of having to wait for the errors to show up to your final users in production and then trying to debug where the problem is. ✨
diff --git a/docs/en/docs/img/tutorial/generate-clients/image01.png b/docs/en/docs/img/tutorial/generate-clients/image01.png
new file mode 100644 (file)
index 0000000..f23d577
Binary files /dev/null and b/docs/en/docs/img/tutorial/generate-clients/image01.png differ
diff --git a/docs/en/docs/img/tutorial/generate-clients/image02.png b/docs/en/docs/img/tutorial/generate-clients/image02.png
new file mode 100644 (file)
index 0000000..f991352
Binary files /dev/null and b/docs/en/docs/img/tutorial/generate-clients/image02.png differ
diff --git a/docs/en/docs/img/tutorial/generate-clients/image03.png b/docs/en/docs/img/tutorial/generate-clients/image03.png
new file mode 100644 (file)
index 0000000..e2514b0
Binary files /dev/null and b/docs/en/docs/img/tutorial/generate-clients/image03.png differ
diff --git a/docs/en/docs/img/tutorial/generate-clients/image04.png b/docs/en/docs/img/tutorial/generate-clients/image04.png
new file mode 100644 (file)
index 0000000..777a695
Binary files /dev/null and b/docs/en/docs/img/tutorial/generate-clients/image04.png differ
diff --git a/docs/en/docs/img/tutorial/generate-clients/image05.png b/docs/en/docs/img/tutorial/generate-clients/image05.png
new file mode 100644 (file)
index 0000000..e1e5317
Binary files /dev/null and b/docs/en/docs/img/tutorial/generate-clients/image05.png differ
diff --git a/docs/en/docs/img/tutorial/generate-clients/image06.png b/docs/en/docs/img/tutorial/generate-clients/image06.png
new file mode 100644 (file)
index 0000000..0e9a100
Binary files /dev/null and b/docs/en/docs/img/tutorial/generate-clients/image06.png differ
diff --git a/docs/en/docs/img/tutorial/generate-clients/image07.png b/docs/en/docs/img/tutorial/generate-clients/image07.png
new file mode 100644 (file)
index 0000000..2788496
Binary files /dev/null and b/docs/en/docs/img/tutorial/generate-clients/image07.png differ
diff --git a/docs/en/docs/img/tutorial/generate-clients/image08.png b/docs/en/docs/img/tutorial/generate-clients/image08.png
new file mode 100644 (file)
index 0000000..509fbd3
Binary files /dev/null and b/docs/en/docs/img/tutorial/generate-clients/image08.png differ
index 5bdd2c54a8ab3a3298fba1cb08502485ed5b8bea..e2a77987218c2a21feefdfad511712b93ef44b90 100644 (file)
@@ -140,6 +140,7 @@ nav:
   - advanced/extending-openapi.md
   - advanced/openapi-callbacks.md
   - advanced/wsgi.md
+  - advanced/generate-clients.md
 - async.md
 - Deployment:
   - deployment/index.md
diff --git a/docs_src/generate_clients/tutorial001.py b/docs_src/generate_clients/tutorial001.py
new file mode 100644 (file)
index 0000000..2d1f91b
--- /dev/null
@@ -0,0 +1,28 @@
+from typing import List
+
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class Item(BaseModel):
+    name: str
+    price: float
+
+
+class ResponseMessage(BaseModel):
+    message: str
+
+
+@app.post("/items/", response_model=ResponseMessage)
+async def create_item(item: Item):
+    return {"message": "item received"}
+
+
+@app.get("/items/", response_model=List[Item])
+async def get_items():
+    return [
+        {"name": "Plumbus", "price": 3},
+        {"name": "Portal Gun", "price": 9001},
+    ]
diff --git a/docs_src/generate_clients/tutorial001_py39.py b/docs_src/generate_clients/tutorial001_py39.py
new file mode 100644 (file)
index 0000000..6a5ae23
--- /dev/null
@@ -0,0 +1,26 @@
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class Item(BaseModel):
+    name: str
+    price: float
+
+
+class ResponseMessage(BaseModel):
+    message: str
+
+
+@app.post("/items/", response_model=ResponseMessage)
+async def create_item(item: Item):
+    return {"message": "item received"}
+
+
+@app.get("/items/", response_model=list[Item])
+async def get_items():
+    return [
+        {"name": "Plumbus", "price": 3},
+        {"name": "Portal Gun", "price": 9001},
+    ]
diff --git a/docs_src/generate_clients/tutorial002.py b/docs_src/generate_clients/tutorial002.py
new file mode 100644 (file)
index 0000000..bd80449
--- /dev/null
@@ -0,0 +1,38 @@
+from typing import List
+
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class Item(BaseModel):
+    name: str
+    price: float
+
+
+class ResponseMessage(BaseModel):
+    message: str
+
+
+class User(BaseModel):
+    username: str
+    email: str
+
+
+@app.post("/items/", response_model=ResponseMessage, tags=["items"])
+async def create_item(item: Item):
+    return {"message": "Item received"}
+
+
+@app.get("/items/", response_model=List[Item], tags=["items"])
+async def get_items():
+    return [
+        {"name": "Plumbus", "price": 3},
+        {"name": "Portal Gun", "price": 9001},
+    ]
+
+
+@app.post("/users/", response_model=ResponseMessage, tags=["users"])
+async def create_user(user: User):
+    return {"message": "User received"}
diff --git a/docs_src/generate_clients/tutorial002_py39.py b/docs_src/generate_clients/tutorial002_py39.py
new file mode 100644 (file)
index 0000000..8330976
--- /dev/null
@@ -0,0 +1,36 @@
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class Item(BaseModel):
+    name: str
+    price: float
+
+
+class ResponseMessage(BaseModel):
+    message: str
+
+
+class User(BaseModel):
+    username: str
+    email: str
+
+
+@app.post("/items/", response_model=ResponseMessage, tags=["items"])
+async def create_item(item: Item):
+    return {"message": "Item received"}
+
+
+@app.get("/items/", response_model=list[Item], tags=["items"])
+async def get_items():
+    return [
+        {"name": "Plumbus", "price": 3},
+        {"name": "Portal Gun", "price": 9001},
+    ]
+
+
+@app.post("/users/", response_model=ResponseMessage, tags=["users"])
+async def create_user(user: User):
+    return {"message": "User received"}
diff --git a/docs_src/generate_clients/tutorial003.py b/docs_src/generate_clients/tutorial003.py
new file mode 100644 (file)
index 0000000..49eab73
--- /dev/null
@@ -0,0 +1,44 @@
+from typing import List
+
+from fastapi import FastAPI
+from fastapi.routing import APIRoute
+from pydantic import BaseModel
+
+
+def custom_generate_unique_id(route: APIRoute):
+    return f"{route.tags[0]}-{route.name}"
+
+
+app = FastAPI(generate_unique_id_function=custom_generate_unique_id)
+
+
+class Item(BaseModel):
+    name: str
+    price: float
+
+
+class ResponseMessage(BaseModel):
+    message: str
+
+
+class User(BaseModel):
+    username: str
+    email: str
+
+
+@app.post("/items/", response_model=ResponseMessage, tags=["items"])
+async def create_item(item: Item):
+    return {"message": "Item received"}
+
+
+@app.get("/items/", response_model=List[Item], tags=["items"])
+async def get_items():
+    return [
+        {"name": "Plumbus", "price": 3},
+        {"name": "Portal Gun", "price": 9001},
+    ]
+
+
+@app.post("/users/", response_model=ResponseMessage, tags=["users"])
+async def create_user(user: User):
+    return {"message": "User received"}
diff --git a/docs_src/generate_clients/tutorial003_py39.py b/docs_src/generate_clients/tutorial003_py39.py
new file mode 100644 (file)
index 0000000..40722cf
--- /dev/null
@@ -0,0 +1,42 @@
+from fastapi import FastAPI
+from fastapi.routing import APIRoute
+from pydantic import BaseModel
+
+
+def custom_generate_unique_id(route: APIRoute):
+    return f"{route.tags[0]}-{route.name}"
+
+
+app = FastAPI(generate_unique_id_function=custom_generate_unique_id)
+
+
+class Item(BaseModel):
+    name: str
+    price: float
+
+
+class ResponseMessage(BaseModel):
+    message: str
+
+
+class User(BaseModel):
+    username: str
+    email: str
+
+
+@app.post("/items/", response_model=ResponseMessage, tags=["items"])
+async def create_item(item: Item):
+    return {"message": "Item received"}
+
+
+@app.get("/items/", response_model=list[Item], tags=["items"])
+async def get_items():
+    return [
+        {"name": "Plumbus", "price": 3},
+        {"name": "Portal Gun", "price": 9001},
+    ]
+
+
+@app.post("/users/", response_model=ResponseMessage, tags=["users"])
+async def create_user(user: User):
+    return {"message": "User received"}
diff --git a/docs_src/generate_clients/tutorial004.py b/docs_src/generate_clients/tutorial004.py
new file mode 100644 (file)
index 0000000..894dc7f
--- /dev/null
@@ -0,0 +1,15 @@
+import json
+from pathlib import Path
+
+file_path = Path("./openapi.json")
+openapi_content = json.loads(file_path.read_text())
+
+for path_data in openapi_content["paths"].values():
+    for operation in path_data.values():
+        tag = operation["tags"][0]
+        operation_id = operation["operationId"]
+        to_remove = f"{tag}-"
+        new_operation_id = operation_id[len(to_remove) :]
+        operation["operationId"] = new_operation_id
+
+file_path.write_text(json.dumps(openapi_content))
index 9fb78719c31c40c80b42f2b740b10aaf59950c48..132a94c9a1c7cfdab758309d9f7edb57cce0fb9f 100644 (file)
@@ -19,6 +19,7 @@ from fastapi.openapi.docs import (
 from fastapi.openapi.utils import get_openapi
 from fastapi.params import Depends
 from fastapi.types import DecoratedCallable
+from fastapi.utils import generate_unique_id
 from starlette.applications import Starlette
 from starlette.datastructures import State
 from starlette.exceptions import ExceptionMiddleware, HTTPException
@@ -68,10 +69,44 @@ class FastAPI(Starlette):
         deprecated: Optional[bool] = None,
         include_in_schema: bool = True,
         swagger_ui_parameters: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
         **extra: Any,
     ) -> None:
         self._debug: bool = debug
+        self.title = title
+        self.description = description
+        self.version = version
+        self.terms_of_service = terms_of_service
+        self.contact = contact
+        self.license_info = license_info
+        self.openapi_url = openapi_url
+        self.openapi_tags = openapi_tags
+        self.root_path_in_servers = root_path_in_servers
+        self.docs_url = docs_url
+        self.redoc_url = redoc_url
+        self.swagger_ui_oauth2_redirect_url = swagger_ui_oauth2_redirect_url
+        self.swagger_ui_init_oauth = swagger_ui_init_oauth
+        self.swagger_ui_parameters = swagger_ui_parameters
+        self.servers = servers or []
+        self.extra = extra
+        self.openapi_version = "3.0.2"
+        self.openapi_schema: Optional[Dict[str, Any]] = None
+        if self.openapi_url:
+            assert self.title, "A title must be provided for OpenAPI, e.g.: 'My API'"
+            assert self.version, "A version must be provided for OpenAPI, e.g.: '2.1.0'"
+        # TODO: remove when discarding the openapi_prefix parameter
+        if openapi_prefix:
+            logger.warning(
+                '"openapi_prefix" has been deprecated in favor of "root_path", which '
+                "follows more closely the ASGI standard, is simpler, and more "
+                "automatic. Check the docs at "
+                "https://fastapi.tiangolo.com/advanced/sub-applications/"
+            )
+        self.root_path = root_path or openapi_prefix
         self.state: State = State()
+        self.dependency_overrides: Dict[Callable[..., Any], Callable[..., Any]] = {}
         self.router: routing.APIRouter = routing.APIRouter(
             routes=routes,
             dependency_overrides_provider=self,
@@ -83,6 +118,7 @@ class FastAPI(Starlette):
             deprecated=deprecated,
             include_in_schema=include_in_schema,
             responses=responses,
+            generate_unique_id_function=generate_unique_id_function,
         )
         self.exception_handlers: Dict[
             Union[int, Type[Exception]],
@@ -99,40 +135,6 @@ class FastAPI(Starlette):
             [] if middleware is None else list(middleware)
         )
         self.middleware_stack: ASGIApp = self.build_middleware_stack()
-
-        self.title = title
-        self.description = description
-        self.version = version
-        self.terms_of_service = terms_of_service
-        self.contact = contact
-        self.license_info = license_info
-        self.servers = servers or []
-        self.openapi_url = openapi_url
-        self.openapi_tags = openapi_tags
-        # TODO: remove when discarding the openapi_prefix parameter
-        if openapi_prefix:
-            logger.warning(
-                '"openapi_prefix" has been deprecated in favor of "root_path", which '
-                "follows more closely the ASGI standard, is simpler, and more "
-                "automatic. Check the docs at "
-                "https://fastapi.tiangolo.com/advanced/sub-applications/"
-            )
-        self.root_path = root_path or openapi_prefix
-        self.root_path_in_servers = root_path_in_servers
-        self.docs_url = docs_url
-        self.redoc_url = redoc_url
-        self.swagger_ui_oauth2_redirect_url = swagger_ui_oauth2_redirect_url
-        self.swagger_ui_init_oauth = swagger_ui_init_oauth
-        self.swagger_ui_parameters = swagger_ui_parameters
-        self.extra = extra
-        self.dependency_overrides: Dict[Callable[..., Any], Callable[..., Any]] = {}
-
-        self.openapi_version = "3.0.2"
-
-        if self.openapi_url:
-            assert self.title, "A title must be provided for OpenAPI, e.g.: 'My API'"
-            assert self.version, "A version must be provided for OpenAPI, e.g.: '2.1.0'"
-        self.openapi_schema: Optional[Dict[str, Any]] = None
         self.setup()
 
     def build_middleware_stack(self) -> ASGIApp:
@@ -286,6 +288,9 @@ class FastAPI(Starlette):
         ),
         name: Optional[str] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> None:
         self.router.add_api_route(
             path,
@@ -311,6 +316,7 @@ class FastAPI(Starlette):
             response_class=response_class,
             name=name,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def api_route(
@@ -338,6 +344,9 @@ class FastAPI(Starlette):
         response_class: Type[Response] = Default(JSONResponse),
         name: Optional[str] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         def decorator(func: DecoratedCallable) -> DecoratedCallable:
             self.router.add_api_route(
@@ -364,6 +373,7 @@ class FastAPI(Starlette):
                 response_class=response_class,
                 name=name,
                 openapi_extra=openapi_extra,
+                generate_unique_id_function=generate_unique_id_function,
             )
             return func
 
@@ -395,6 +405,9 @@ class FastAPI(Starlette):
         include_in_schema: bool = True,
         default_response_class: Type[Response] = Default(JSONResponse),
         callbacks: Optional[List[BaseRoute]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> None:
         self.router.include_router(
             router,
@@ -406,6 +419,7 @@ class FastAPI(Starlette):
             include_in_schema=include_in_schema,
             default_response_class=default_response_class,
             callbacks=callbacks,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def get(
@@ -433,6 +447,9 @@ class FastAPI(Starlette):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.router.get(
             path,
@@ -457,6 +474,7 @@ class FastAPI(Starlette):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def put(
@@ -484,6 +502,9 @@ class FastAPI(Starlette):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.router.put(
             path,
@@ -508,6 +529,7 @@ class FastAPI(Starlette):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def post(
@@ -535,6 +557,9 @@ class FastAPI(Starlette):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.router.post(
             path,
@@ -559,6 +584,7 @@ class FastAPI(Starlette):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def delete(
@@ -586,6 +612,9 @@ class FastAPI(Starlette):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.router.delete(
             path,
@@ -610,6 +639,7 @@ class FastAPI(Starlette):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def options(
@@ -637,6 +667,9 @@ class FastAPI(Starlette):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.router.options(
             path,
@@ -661,6 +694,7 @@ class FastAPI(Starlette):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def head(
@@ -688,6 +722,9 @@ class FastAPI(Starlette):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.router.head(
             path,
@@ -712,6 +749,7 @@ class FastAPI(Starlette):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def patch(
@@ -739,6 +777,9 @@ class FastAPI(Starlette):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.router.patch(
             path,
@@ -763,6 +804,7 @@ class FastAPI(Starlette):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def trace(
@@ -790,6 +832,9 @@ class FastAPI(Starlette):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.router.trace(
             path,
@@ -814,4 +859,5 @@ class FastAPI(Starlette):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
index aff76b15edfed0717181967aee38322aa4c83815..58a748d04919facccfeed8e5f74a460d1633ba1b 100644 (file)
@@ -1,5 +1,6 @@
 import http.client
 import inspect
+import warnings
 from enum import Enum
 from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, Type, Union, cast
 
@@ -140,7 +141,15 @@ def get_openapi_operation_request_body(
     return request_body_oai
 
 
-def generate_operation_id(*, route: routing.APIRoute, method: str) -> str:
+def generate_operation_id(
+    *, route: routing.APIRoute, method: str
+) -> str:  # pragma: nocover
+    warnings.warn(
+        "fastapi.openapi.utils.generate_operation_id() was deprecated, "
+        "it is not used internally, and will be removed soon",
+        DeprecationWarning,
+        stacklevel=2,
+    )
     if route.operation_id:
         return route.operation_id
     path: str = route.path_format
@@ -154,7 +163,7 @@ def generate_operation_summary(*, route: routing.APIRoute, method: str) -> str:
 
 
 def get_openapi_operation_metadata(
-    *, route: routing.APIRoute, method: str
+    *, route: routing.APIRoute, method: str, operation_ids: Set[str]
 ) -> Dict[str, Any]:
     operation: Dict[str, Any] = {}
     if route.tags:
@@ -162,14 +171,25 @@ def get_openapi_operation_metadata(
     operation["summary"] = generate_operation_summary(route=route, method=method)
     if route.description:
         operation["description"] = route.description
-    operation["operationId"] = generate_operation_id(route=route, method=method)
+    operation_id = route.operation_id or route.unique_id
+    if operation_id in operation_ids:
+        message = (
+            f"Duplicate Operation ID {operation_id} for function "
+            + f"{route.endpoint.__name__}"
+        )
+        file_name = getattr(route.endpoint, "__globals__", {}).get("__file__")
+        if file_name:
+            message += f" at {file_name}"
+        warnings.warn(message)
+    operation_ids.add(operation_id)
+    operation["operationId"] = operation_id
     if route.deprecated:
         operation["deprecated"] = route.deprecated
     return operation
 
 
 def get_openapi_path(
-    *, route: routing.APIRoute, model_name_map: Dict[type, str]
+    *, route: routing.APIRoute, model_name_map: Dict[type, str], operation_ids: Set[str]
 ) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]:
     path = {}
     security_schemes: Dict[str, Any] = {}
@@ -183,7 +203,9 @@ def get_openapi_path(
     route_response_media_type: Optional[str] = current_response_class.media_type
     if route.include_in_schema:
         for method in route.methods:
-            operation = get_openapi_operation_metadata(route=route, method=method)
+            operation = get_openapi_operation_metadata(
+                route=route, method=method, operation_ids=operation_ids
+            )
             parameters: List[Dict[str, Any]] = []
             flat_dependant = get_flat_dependant(route.dependant, skip_repeats=True)
             security_definitions, operation_security = get_openapi_security_definitions(
@@ -217,7 +239,9 @@ def get_openapi_path(
                             cb_security_schemes,
                             cb_definitions,
                         ) = get_openapi_path(
-                            route=callback, model_name_map=model_name_map
+                            route=callback,
+                            model_name_map=model_name_map,
+                            operation_ids=operation_ids,
                         )
                         callbacks[callback.name] = {callback.path: cb_path}
                 operation["callbacks"] = callbacks
@@ -384,6 +408,7 @@ def get_openapi(
         output["servers"] = servers
     components: Dict[str, Dict[str, Any]] = {}
     paths: Dict[str, Dict[str, Any]] = {}
+    operation_ids: Set[str] = set()
     flat_models = get_flat_models_from_routes(routes)
     model_name_map = get_model_name_map(flat_models)
     definitions = get_model_definitions(
@@ -391,7 +416,9 @@ def get_openapi(
     )
     for route in routes:
         if isinstance(route, routing.APIRoute):
-            result = get_openapi_path(route=route, model_name_map=model_name_map)
+            result = get_openapi_path(
+                route=route, model_name_map=model_name_map, operation_ids=operation_ids
+            )
             if result:
                 path, security_schemes, path_definitions = result
                 if path:
index 7dae04521ca3d1c167bbbbec03cae4de9d8453c6..0f416ac42e1df52e716c4a7be862bd0271d2b23f 100644 (file)
@@ -34,7 +34,7 @@ from fastapi.types import DecoratedCallable
 from fastapi.utils import (
     create_cloned_field,
     create_response_field,
-    generate_operation_id_for_path,
+    generate_unique_id,
     get_value_or_default,
 )
 from pydantic import BaseModel
@@ -335,21 +335,47 @@ class APIRoute(routing.Route):
         dependency_overrides_provider: Optional[Any] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Union[
+            Callable[["APIRoute"], str], DefaultPlaceholder
+        ] = Default(generate_unique_id),
     ) -> None:
-        # normalise enums e.g. http.HTTPStatus
-        if isinstance(status_code, IntEnum):
-            status_code = int(status_code)
         self.path = path
         self.endpoint = endpoint
+        self.response_model = response_model
+        self.summary = summary
+        self.response_description = response_description
+        self.deprecated = deprecated
+        self.operation_id = operation_id
+        self.response_model_include = response_model_include
+        self.response_model_exclude = response_model_exclude
+        self.response_model_by_alias = response_model_by_alias
+        self.response_model_exclude_unset = response_model_exclude_unset
+        self.response_model_exclude_defaults = response_model_exclude_defaults
+        self.response_model_exclude_none = response_model_exclude_none
+        self.include_in_schema = include_in_schema
+        self.response_class = response_class
+        self.dependency_overrides_provider = dependency_overrides_provider
+        self.callbacks = callbacks
+        self.openapi_extra = openapi_extra
+        self.generate_unique_id_function = generate_unique_id_function
+        self.tags = tags or []
+        self.responses = responses or {}
         self.name = get_name(endpoint) if name is None else name
         self.path_regex, self.path_format, self.param_convertors = compile_path(path)
         if methods is None:
             methods = ["GET"]
         self.methods: Set[str] = set([method.upper() for method in methods])
-        self.unique_id = generate_operation_id_for_path(
-            name=self.name, path=self.path_format, method=list(methods)[0]
-        )
-        self.response_model = response_model
+        if isinstance(generate_unique_id_function, DefaultPlaceholder):
+            current_generate_unique_id: Callable[
+                ["APIRoute"], str
+            ] = generate_unique_id_function.value
+        else:
+            current_generate_unique_id = generate_unique_id_function
+        self.unique_id = self.operation_id or current_generate_unique_id(self)
+        # normalize enums e.g. http.HTTPStatus
+        if isinstance(status_code, IntEnum):
+            status_code = int(status_code)
+        self.status_code = status_code
         if self.response_model:
             assert (
                 status_code not in STATUS_CODES_WITH_NO_BODY
@@ -371,19 +397,14 @@ class APIRoute(routing.Route):
         else:
             self.response_field = None  # type: ignore
             self.secure_cloned_response_field = None
-        self.status_code = status_code
-        self.tags = tags or []
         if dependencies:
             self.dependencies = list(dependencies)
         else:
             self.dependencies = []
-        self.summary = summary
         self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "")
         # if a "form feed" character (page break) is found in the description text,
         # truncate description text to the content preceding the first "form feed"
         self.description = self.description.split("\f")[0]
-        self.response_description = response_description
-        self.responses = responses or {}
         response_fields = {}
         for additional_status_code, response in self.responses.items():
             assert isinstance(response, dict), "An additional response must be a dict"
@@ -399,16 +420,6 @@ class APIRoute(routing.Route):
             self.response_fields: Dict[Union[int, str], ModelField] = response_fields
         else:
             self.response_fields = {}
-        self.deprecated = deprecated
-        self.operation_id = operation_id
-        self.response_model_include = response_model_include
-        self.response_model_exclude = response_model_exclude
-        self.response_model_by_alias = response_model_by_alias
-        self.response_model_exclude_unset = response_model_exclude_unset
-        self.response_model_exclude_defaults = response_model_exclude_defaults
-        self.response_model_exclude_none = response_model_exclude_none
-        self.include_in_schema = include_in_schema
-        self.response_class = response_class
 
         assert callable(endpoint), "An endpoint must be a callable"
         self.dependant = get_dependant(path=self.path_format, call=self.endpoint)
@@ -418,10 +429,7 @@ class APIRoute(routing.Route):
                 get_parameterless_sub_dependant(depends=depends, path=self.path_format),
             )
         self.body_field = get_body_field(dependant=self.dependant, name=self.unique_id)
-        self.dependency_overrides_provider = dependency_overrides_provider
-        self.callbacks = callbacks
         self.app = request_response(self.get_route_handler())
-        self.openapi_extra = openapi_extra
 
     def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
         return get_request_handler(
@@ -465,6 +473,9 @@ class APIRouter(routing.Router):
         on_shutdown: Optional[Sequence[Callable[[], Any]]] = None,
         deprecated: Optional[bool] = None,
         include_in_schema: bool = True,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> None:
         super().__init__(
             routes=routes,  # type: ignore # in Starlette
@@ -488,6 +499,7 @@ class APIRouter(routing.Router):
         self.dependency_overrides_provider = dependency_overrides_provider
         self.route_class = route_class
         self.default_response_class = default_response_class
+        self.generate_unique_id_function = generate_unique_id_function
 
     def add_api_route(
         self,
@@ -519,6 +531,9 @@ class APIRouter(routing.Router):
         route_class_override: Optional[Type[APIRoute]] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Union[
+            Callable[[APIRoute], str], DefaultPlaceholder
+        ] = Default(generate_unique_id),
     ) -> None:
         route_class = route_class_override or self.route_class
         responses = responses or {}
@@ -535,6 +550,9 @@ class APIRouter(routing.Router):
         current_callbacks = self.callbacks.copy()
         if callbacks:
             current_callbacks.extend(callbacks)
+        current_generate_unique_id = get_value_or_default(
+            generate_unique_id_function, self.generate_unique_id_function
+        )
         route = route_class(
             self.prefix + path,
             endpoint=endpoint,
@@ -561,6 +579,7 @@ class APIRouter(routing.Router):
             dependency_overrides_provider=self.dependency_overrides_provider,
             callbacks=current_callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=current_generate_unique_id,
         )
         self.routes.append(route)
 
@@ -590,6 +609,9 @@ class APIRouter(routing.Router):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         def decorator(func: DecoratedCallable) -> DecoratedCallable:
             self.add_api_route(
@@ -617,6 +639,7 @@ class APIRouter(routing.Router):
                 name=name,
                 callbacks=callbacks,
                 openapi_extra=openapi_extra,
+                generate_unique_id_function=generate_unique_id_function,
             )
             return func
 
@@ -654,6 +677,9 @@ class APIRouter(routing.Router):
         callbacks: Optional[List[BaseRoute]] = None,
         deprecated: Optional[bool] = None,
         include_in_schema: bool = True,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> None:
         if prefix:
             assert prefix.startswith("/"), "A path prefix must start with '/'"
@@ -694,6 +720,12 @@ class APIRouter(routing.Router):
                     current_callbacks.extend(callbacks)
                 if route.callbacks:
                     current_callbacks.extend(route.callbacks)
+                current_generate_unique_id = get_value_or_default(
+                    route.generate_unique_id_function,
+                    router.generate_unique_id_function,
+                    generate_unique_id_function,
+                    self.generate_unique_id_function,
+                )
                 self.add_api_route(
                     prefix + route.path,
                     route.endpoint,
@@ -722,6 +754,7 @@ class APIRouter(routing.Router):
                     route_class_override=type(route),
                     callbacks=current_callbacks,
                     openapi_extra=route.openapi_extra,
+                    generate_unique_id_function=current_generate_unique_id,
                 )
             elif isinstance(route, routing.Route):
                 methods = list(route.methods or [])  # type: ignore # in Starlette
@@ -770,6 +803,9 @@ class APIRouter(routing.Router):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.api_route(
             path=path,
@@ -795,6 +831,7 @@ class APIRouter(routing.Router):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def put(
@@ -822,6 +859,9 @@ class APIRouter(routing.Router):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.api_route(
             path=path,
@@ -847,6 +887,7 @@ class APIRouter(routing.Router):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def post(
@@ -874,6 +915,9 @@ class APIRouter(routing.Router):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.api_route(
             path=path,
@@ -899,6 +943,7 @@ class APIRouter(routing.Router):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def delete(
@@ -926,6 +971,9 @@ class APIRouter(routing.Router):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.api_route(
             path=path,
@@ -951,6 +999,7 @@ class APIRouter(routing.Router):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def options(
@@ -978,6 +1027,9 @@ class APIRouter(routing.Router):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.api_route(
             path=path,
@@ -1003,6 +1055,7 @@ class APIRouter(routing.Router):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def head(
@@ -1030,6 +1083,9 @@ class APIRouter(routing.Router):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.api_route(
             path=path,
@@ -1055,6 +1111,7 @@ class APIRouter(routing.Router):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def patch(
@@ -1082,6 +1139,9 @@ class APIRouter(routing.Router):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
         return self.api_route(
             path=path,
@@ -1107,6 +1167,7 @@ class APIRouter(routing.Router):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
 
     def trace(
@@ -1134,6 +1195,9 @@ class APIRouter(routing.Router):
         name: Optional[str] = None,
         callbacks: Optional[List[BaseRoute]] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
+        generate_unique_id_function: Callable[[APIRoute], str] = Default(
+            generate_unique_id
+        ),
     ) -> Callable[[DecoratedCallable], DecoratedCallable]:
 
         return self.api_route(
@@ -1160,4 +1224,5 @@ class APIRouter(routing.Router):
             name=name,
             callbacks=callbacks,
             openapi_extra=openapi_extra,
+            generate_unique_id_function=generate_unique_id_function,
         )
index 8913d85b2dc40cec52ea02e502c7b085b0172cb6..b9301499a27a70f612fc62bb6b6b9fd102b9cd36 100644 (file)
@@ -1,8 +1,9 @@
 import functools
 import re
+import warnings
 from dataclasses import is_dataclass
 from enum import Enum
-from typing import Any, Dict, Optional, Set, Type, Union, cast
+from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Type, Union, cast
 
 import fastapi
 from fastapi.datastructures import DefaultPlaceholder, DefaultType
@@ -13,6 +14,9 @@ from pydantic.fields import FieldInfo, ModelField, UndefinedType
 from pydantic.schema import model_process_schema
 from pydantic.utils import lenient_issubclass
 
+if TYPE_CHECKING:  # pragma: nocover
+    from .routing import APIRoute
+
 
 def get_model_definitions(
     *,
@@ -119,13 +123,29 @@ def create_cloned_field(
     return new_field
 
 
-def generate_operation_id_for_path(*, name: str, path: str, method: str) -> str:
+def generate_operation_id_for_path(
+    *, name: str, path: str, method: str
+) -> str:  # pragma: nocover
+    warnings.warn(
+        "fastapi.utils.generate_operation_id_for_path() was deprecated, "
+        "it is not used internally, and will be removed soon",
+        DeprecationWarning,
+        stacklevel=2,
+    )
     operation_id = name + path
     operation_id = re.sub("[^0-9a-zA-Z_]", "_", operation_id)
     operation_id = operation_id + "_" + method.lower()
     return operation_id
 
 
+def generate_unique_id(route: "APIRoute") -> str:
+    operation_id = route.name + route.path_format
+    operation_id = re.sub("[^0-9a-zA-Z_]", "_", operation_id)
+    assert route.methods
+    operation_id = operation_id + "_" + list(route.methods)[0].lower()
+    return operation_id
+
+
 def deep_dict_update(main_dict: Dict[Any, Any], update_dict: Dict[Any, Any]) -> None:
     for key in update_dict:
         if (
diff --git a/tests/test_generate_unique_id_function.py b/tests/test_generate_unique_id_function.py
new file mode 100644 (file)
index 0000000..ffc0e38
--- /dev/null
@@ -0,0 +1,1617 @@
+import warnings
+from typing import List
+
+from fastapi import APIRouter, FastAPI
+from fastapi.routing import APIRoute
+from fastapi.testclient import TestClient
+from pydantic import BaseModel
+
+
+def custom_generate_unique_id(route: APIRoute):
+    return f"foo_{route.name}"
+
+
+def custom_generate_unique_id2(route: APIRoute):
+    return f"bar_{route.name}"
+
+
+def custom_generate_unique_id3(route: APIRoute):
+    return f"baz_{route.name}"
+
+
+class Item(BaseModel):
+    name: str
+    price: float
+
+
+class Message(BaseModel):
+    title: str
+    description: str
+
+
+def test_top_level_generate_unique_id():
+    app = FastAPI(generate_unique_id_function=custom_generate_unique_id)
+    router = APIRouter()
+
+    @app.post("/", response_model=List[Item], responses={404: {"model": List[Message]}})
+    def post_root(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    @router.post(
+        "/router", response_model=List[Item], responses={404: {"model": List[Message]}}
+    )
+    def post_router(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    app.include_router(router)
+    client = TestClient(app)
+    response = client.get("/openapi.json")
+    data = response.json()
+    assert data == {
+        "openapi": "3.0.2",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/": {
+                "post": {
+                    "summary": "Post Root",
+                    "operationId": "foo_post_root",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_foo_post_root"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Foo Post Root",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Foo Post Root",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+            "/router": {
+                "post": {
+                    "summary": "Post Router",
+                    "operationId": "foo_post_router",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_foo_post_router"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Foo Post Router",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Foo Post Router",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+        },
+        "components": {
+            "schemas": {
+                "Body_foo_post_root": {
+                    "title": "Body_foo_post_root",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "Body_foo_post_router": {
+                    "title": "Body_foo_post_router",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+                "Item": {
+                    "title": "Item",
+                    "required": ["name", "price"],
+                    "type": "object",
+                    "properties": {
+                        "name": {"title": "Name", "type": "string"},
+                        "price": {"title": "Price", "type": "number"},
+                    },
+                },
+                "Message": {
+                    "title": "Message",
+                    "required": ["title", "description"],
+                    "type": "object",
+                    "properties": {
+                        "title": {"title": "Title", "type": "string"},
+                        "description": {"title": "Description", "type": "string"},
+                    },
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {"type": "string"},
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+            }
+        },
+    }
+
+
+def test_router_overrides_generate_unique_id():
+    app = FastAPI(generate_unique_id_function=custom_generate_unique_id)
+    router = APIRouter(generate_unique_id_function=custom_generate_unique_id2)
+
+    @app.post("/", response_model=List[Item], responses={404: {"model": List[Message]}})
+    def post_root(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    @router.post(
+        "/router", response_model=List[Item], responses={404: {"model": List[Message]}}
+    )
+    def post_router(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    app.include_router(router)
+    client = TestClient(app)
+    response = client.get("/openapi.json")
+    data = response.json()
+    assert data == {
+        "openapi": "3.0.2",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/": {
+                "post": {
+                    "summary": "Post Root",
+                    "operationId": "foo_post_root",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_foo_post_root"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Foo Post Root",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Foo Post Root",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+            "/router": {
+                "post": {
+                    "summary": "Post Router",
+                    "operationId": "bar_post_router",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_bar_post_router"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Bar Post Router",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Bar Post Router",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+        },
+        "components": {
+            "schemas": {
+                "Body_bar_post_router": {
+                    "title": "Body_bar_post_router",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "Body_foo_post_root": {
+                    "title": "Body_foo_post_root",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+                "Item": {
+                    "title": "Item",
+                    "required": ["name", "price"],
+                    "type": "object",
+                    "properties": {
+                        "name": {"title": "Name", "type": "string"},
+                        "price": {"title": "Price", "type": "number"},
+                    },
+                },
+                "Message": {
+                    "title": "Message",
+                    "required": ["title", "description"],
+                    "type": "object",
+                    "properties": {
+                        "title": {"title": "Title", "type": "string"},
+                        "description": {"title": "Description", "type": "string"},
+                    },
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {"type": "string"},
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+            }
+        },
+    }
+
+
+def test_router_include_overrides_generate_unique_id():
+    app = FastAPI(generate_unique_id_function=custom_generate_unique_id)
+    router = APIRouter(generate_unique_id_function=custom_generate_unique_id2)
+
+    @app.post("/", response_model=List[Item], responses={404: {"model": List[Message]}})
+    def post_root(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    @router.post(
+        "/router", response_model=List[Item], responses={404: {"model": List[Message]}}
+    )
+    def post_router(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    app.include_router(router, generate_unique_id_function=custom_generate_unique_id3)
+    client = TestClient(app)
+    response = client.get("/openapi.json")
+    data = response.json()
+    assert data == {
+        "openapi": "3.0.2",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/": {
+                "post": {
+                    "summary": "Post Root",
+                    "operationId": "foo_post_root",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_foo_post_root"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Foo Post Root",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Foo Post Root",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+            "/router": {
+                "post": {
+                    "summary": "Post Router",
+                    "operationId": "bar_post_router",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_bar_post_router"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Bar Post Router",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Bar Post Router",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+        },
+        "components": {
+            "schemas": {
+                "Body_bar_post_router": {
+                    "title": "Body_bar_post_router",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "Body_foo_post_root": {
+                    "title": "Body_foo_post_root",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+                "Item": {
+                    "title": "Item",
+                    "required": ["name", "price"],
+                    "type": "object",
+                    "properties": {
+                        "name": {"title": "Name", "type": "string"},
+                        "price": {"title": "Price", "type": "number"},
+                    },
+                },
+                "Message": {
+                    "title": "Message",
+                    "required": ["title", "description"],
+                    "type": "object",
+                    "properties": {
+                        "title": {"title": "Title", "type": "string"},
+                        "description": {"title": "Description", "type": "string"},
+                    },
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {"type": "string"},
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+            }
+        },
+    }
+
+
+def test_subrouter_top_level_include_overrides_generate_unique_id():
+    app = FastAPI(generate_unique_id_function=custom_generate_unique_id)
+    router = APIRouter()
+    sub_router = APIRouter(generate_unique_id_function=custom_generate_unique_id2)
+
+    @app.post("/", response_model=List[Item], responses={404: {"model": List[Message]}})
+    def post_root(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    @router.post(
+        "/router", response_model=List[Item], responses={404: {"model": List[Message]}}
+    )
+    def post_router(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    @sub_router.post(
+        "/subrouter",
+        response_model=List[Item],
+        responses={404: {"model": List[Message]}},
+    )
+    def post_subrouter(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    router.include_router(sub_router)
+    app.include_router(router, generate_unique_id_function=custom_generate_unique_id3)
+    client = TestClient(app)
+    response = client.get("/openapi.json")
+    data = response.json()
+    assert data == {
+        "openapi": "3.0.2",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/": {
+                "post": {
+                    "summary": "Post Root",
+                    "operationId": "foo_post_root",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_foo_post_root"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Foo Post Root",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Foo Post Root",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+            "/router": {
+                "post": {
+                    "summary": "Post Router",
+                    "operationId": "baz_post_router",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_baz_post_router"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Baz Post Router",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Baz Post Router",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+            "/subrouter": {
+                "post": {
+                    "summary": "Post Subrouter",
+                    "operationId": "bar_post_subrouter",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_bar_post_subrouter"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Bar Post Subrouter",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Bar Post Subrouter",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+        },
+        "components": {
+            "schemas": {
+                "Body_bar_post_subrouter": {
+                    "title": "Body_bar_post_subrouter",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "Body_baz_post_router": {
+                    "title": "Body_baz_post_router",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "Body_foo_post_root": {
+                    "title": "Body_foo_post_root",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+                "Item": {
+                    "title": "Item",
+                    "required": ["name", "price"],
+                    "type": "object",
+                    "properties": {
+                        "name": {"title": "Name", "type": "string"},
+                        "price": {"title": "Price", "type": "number"},
+                    },
+                },
+                "Message": {
+                    "title": "Message",
+                    "required": ["title", "description"],
+                    "type": "object",
+                    "properties": {
+                        "title": {"title": "Title", "type": "string"},
+                        "description": {"title": "Description", "type": "string"},
+                    },
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {"type": "string"},
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+            }
+        },
+    }
+
+
+def test_router_path_operation_overrides_generate_unique_id():
+    app = FastAPI(generate_unique_id_function=custom_generate_unique_id)
+    router = APIRouter(generate_unique_id_function=custom_generate_unique_id2)
+
+    @app.post("/", response_model=List[Item], responses={404: {"model": List[Message]}})
+    def post_root(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    @router.post(
+        "/router",
+        response_model=List[Item],
+        responses={404: {"model": List[Message]}},
+        generate_unique_id_function=custom_generate_unique_id3,
+    )
+    def post_router(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    app.include_router(router)
+    client = TestClient(app)
+    response = client.get("/openapi.json")
+    data = response.json()
+    assert data == {
+        "openapi": "3.0.2",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/": {
+                "post": {
+                    "summary": "Post Root",
+                    "operationId": "foo_post_root",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_foo_post_root"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Foo Post Root",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Foo Post Root",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+            "/router": {
+                "post": {
+                    "summary": "Post Router",
+                    "operationId": "baz_post_router",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_baz_post_router"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Baz Post Router",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Baz Post Router",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+        },
+        "components": {
+            "schemas": {
+                "Body_baz_post_router": {
+                    "title": "Body_baz_post_router",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "Body_foo_post_root": {
+                    "title": "Body_foo_post_root",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+                "Item": {
+                    "title": "Item",
+                    "required": ["name", "price"],
+                    "type": "object",
+                    "properties": {
+                        "name": {"title": "Name", "type": "string"},
+                        "price": {"title": "Price", "type": "number"},
+                    },
+                },
+                "Message": {
+                    "title": "Message",
+                    "required": ["title", "description"],
+                    "type": "object",
+                    "properties": {
+                        "title": {"title": "Title", "type": "string"},
+                        "description": {"title": "Description", "type": "string"},
+                    },
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {"type": "string"},
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+            }
+        },
+    }
+
+
+def test_app_path_operation_overrides_generate_unique_id():
+    app = FastAPI(generate_unique_id_function=custom_generate_unique_id)
+    router = APIRouter(generate_unique_id_function=custom_generate_unique_id2)
+
+    @app.post(
+        "/",
+        response_model=List[Item],
+        responses={404: {"model": List[Message]}},
+        generate_unique_id_function=custom_generate_unique_id3,
+    )
+    def post_root(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    @router.post(
+        "/router",
+        response_model=List[Item],
+        responses={404: {"model": List[Message]}},
+    )
+    def post_router(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    app.include_router(router)
+    client = TestClient(app)
+    response = client.get("/openapi.json")
+    data = response.json()
+    assert data == {
+        "openapi": "3.0.2",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/": {
+                "post": {
+                    "summary": "Post Root",
+                    "operationId": "baz_post_root",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_baz_post_root"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Baz Post Root",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Baz Post Root",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+            "/router": {
+                "post": {
+                    "summary": "Post Router",
+                    "operationId": "bar_post_router",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_bar_post_router"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Bar Post Router",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Bar Post Router",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+        },
+        "components": {
+            "schemas": {
+                "Body_bar_post_router": {
+                    "title": "Body_bar_post_router",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "Body_baz_post_root": {
+                    "title": "Body_baz_post_root",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+                "Item": {
+                    "title": "Item",
+                    "required": ["name", "price"],
+                    "type": "object",
+                    "properties": {
+                        "name": {"title": "Name", "type": "string"},
+                        "price": {"title": "Price", "type": "number"},
+                    },
+                },
+                "Message": {
+                    "title": "Message",
+                    "required": ["title", "description"],
+                    "type": "object",
+                    "properties": {
+                        "title": {"title": "Title", "type": "string"},
+                        "description": {"title": "Description", "type": "string"},
+                    },
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {"type": "string"},
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+            }
+        },
+    }
+
+
+def test_callback_override_generate_unique_id():
+    app = FastAPI(generate_unique_id_function=custom_generate_unique_id)
+    callback_router = APIRouter(generate_unique_id_function=custom_generate_unique_id2)
+
+    @callback_router.post(
+        "/post-callback",
+        response_model=List[Item],
+        responses={404: {"model": List[Message]}},
+        generate_unique_id_function=custom_generate_unique_id3,
+    )
+    def post_callback(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    @app.post(
+        "/",
+        response_model=List[Item],
+        responses={404: {"model": List[Message]}},
+        generate_unique_id_function=custom_generate_unique_id3,
+        callbacks=callback_router.routes,
+    )
+    def post_root(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    @app.post(
+        "/tocallback",
+        response_model=List[Item],
+        responses={404: {"model": List[Message]}},
+    )
+    def post_with_callback(item1: Item, item2: Item):
+        return item1, item2  # pragma: nocover
+
+    client = TestClient(app)
+    response = client.get("/openapi.json")
+    data = response.json()
+    assert data == {
+        "openapi": "3.0.2",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/": {
+                "post": {
+                    "summary": "Post Root",
+                    "operationId": "baz_post_root",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_baz_post_root"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Baz Post Root",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Baz Post Root",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                    "callbacks": {
+                        "post_callback": {
+                            "/post-callback": {
+                                "post": {
+                                    "summary": "Post Callback",
+                                    "operationId": "baz_post_callback",
+                                    "requestBody": {
+                                        "content": {
+                                            "application/json": {
+                                                "schema": {
+                                                    "$ref": "#/components/schemas/Body_baz_post_callback"
+                                                }
+                                            }
+                                        },
+                                        "required": True,
+                                    },
+                                    "responses": {
+                                        "200": {
+                                            "description": "Successful Response",
+                                            "content": {
+                                                "application/json": {
+                                                    "schema": {
+                                                        "title": "Response Baz Post Callback",
+                                                        "type": "array",
+                                                        "items": {
+                                                            "$ref": "#/components/schemas/Item"
+                                                        },
+                                                    }
+                                                }
+                                            },
+                                        },
+                                        "404": {
+                                            "description": "Not Found",
+                                            "content": {
+                                                "application/json": {
+                                                    "schema": {
+                                                        "title": "Response 404 Baz Post Callback",
+                                                        "type": "array",
+                                                        "items": {
+                                                            "$ref": "#/components/schemas/Message"
+                                                        },
+                                                    }
+                                                }
+                                            },
+                                        },
+                                        "422": {
+                                            "description": "Validation Error",
+                                            "content": {
+                                                "application/json": {
+                                                    "schema": {
+                                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                                    }
+                                                }
+                                            },
+                                        },
+                                    },
+                                }
+                            }
+                        }
+                    },
+                }
+            },
+            "/tocallback": {
+                "post": {
+                    "summary": "Post With Callback",
+                    "operationId": "foo_post_with_callback",
+                    "requestBody": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/Body_foo_post_with_callback"
+                                }
+                            }
+                        },
+                        "required": True,
+                    },
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response Foo Post With Callback",
+                                        "type": "array",
+                                        "items": {"$ref": "#/components/schemas/Item"},
+                                    }
+                                }
+                            },
+                        },
+                        "404": {
+                            "description": "Not Found",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "title": "Response 404 Foo Post With Callback",
+                                        "type": "array",
+                                        "items": {
+                                            "$ref": "#/components/schemas/Message"
+                                        },
+                                    }
+                                }
+                            },
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                }
+            },
+        },
+        "components": {
+            "schemas": {
+                "Body_baz_post_callback": {
+                    "title": "Body_baz_post_callback",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "Body_baz_post_root": {
+                    "title": "Body_baz_post_root",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "Body_foo_post_with_callback": {
+                    "title": "Body_foo_post_with_callback",
+                    "required": ["item1", "item2"],
+                    "type": "object",
+                    "properties": {
+                        "item1": {"$ref": "#/components/schemas/Item"},
+                        "item2": {"$ref": "#/components/schemas/Item"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+                "Item": {
+                    "title": "Item",
+                    "required": ["name", "price"],
+                    "type": "object",
+                    "properties": {
+                        "name": {"title": "Name", "type": "string"},
+                        "price": {"title": "Price", "type": "number"},
+                    },
+                },
+                "Message": {
+                    "title": "Message",
+                    "required": ["title", "description"],
+                    "type": "object",
+                    "properties": {
+                        "title": {"title": "Title", "type": "string"},
+                        "description": {"title": "Description", "type": "string"},
+                    },
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {"type": "string"},
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+            }
+        },
+    }
+
+
+def test_warn_duplicate_operation_id():
+    def broken_operation_id(route: APIRoute):
+        return "foo"
+
+    app = FastAPI(generate_unique_id_function=broken_operation_id)
+
+    @app.post("/")
+    def post_root(item1: Item):
+        return item1  # pragma: nocover
+
+    @app.post("/second")
+    def post_second(item1: Item):
+        return item1  # pragma: nocover
+
+    @app.post("/third")
+    def post_third(item1: Item):
+        return item1  # pragma: nocover
+
+    client = TestClient(app)
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter("always")
+        client.get("/openapi.json")
+        assert len(w) == 2
+        assert issubclass(w[-1].category, UserWarning)
+        assert "Duplicate Operation ID" in str(w[-1].message)
index c46cb6701453d9bd7a189f097949d9e7a7ff3085..5dd7e7098d6ae0f4a6ccc904997c551e18e34089 100644 (file)
@@ -1,3 +1,5 @@
+import warnings
+
 import pytest
 from fastapi import APIRouter, Depends, FastAPI, Response
 from fastapi.responses import JSONResponse
@@ -343,7 +345,11 @@ client = TestClient(app)
 
 def test_openapi():
     client = TestClient(app)
-    response = client.get("/openapi.json")
+    with warnings.catch_warnings(record=True) as w:
+        warnings.simplefilter("always")
+        response = client.get("/openapi.json")
+        assert issubclass(w[-1].category, UserWarning)
+        assert "Duplicate Operation ID" in str(w[-1].message)
     assert response.json() == openapi_schema
 
 
diff --git a/tests/test_tutorial/test_generate_clients/__init__.py b/tests/test_tutorial/test_generate_clients/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_tutorial/test_generate_clients/test_tutorial003.py b/tests/test_tutorial/test_generate_clients/test_tutorial003.py
new file mode 100644 (file)
index 0000000..d791231
--- /dev/null
@@ -0,0 +1,188 @@
+from fastapi.testclient import TestClient
+
+from docs_src.generate_clients.tutorial003 import app
+
+client = TestClient(app)
+
+openapi_schema = {
+    "openapi": "3.0.2",
+    "info": {"title": "FastAPI", "version": "0.1.0"},
+    "paths": {
+        "/items/": {
+            "get": {
+                "tags": ["items"],
+                "summary": "Get Items",
+                "operationId": "items-get_items",
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "title": "Response Items-Get Items",
+                                    "type": "array",
+                                    "items": {"$ref": "#/components/schemas/Item"},
+                                }
+                            }
+                        },
+                    }
+                },
+            },
+            "post": {
+                "tags": ["items"],
+                "summary": "Create Item",
+                "operationId": "items-create_item",
+                "requestBody": {
+                    "content": {
+                        "application/json": {
+                            "schema": {"$ref": "#/components/schemas/Item"}
+                        }
+                    },
+                    "required": True,
+                },
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/ResponseMessage"
+                                }
+                            }
+                        },
+                    },
+                    "422": {
+                        "description": "Validation Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HTTPValidationError"
+                                }
+                            }
+                        },
+                    },
+                },
+            },
+        },
+        "/users/": {
+            "post": {
+                "tags": ["users"],
+                "summary": "Create User",
+                "operationId": "users-create_user",
+                "requestBody": {
+                    "content": {
+                        "application/json": {
+                            "schema": {"$ref": "#/components/schemas/User"}
+                        }
+                    },
+                    "required": True,
+                },
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/ResponseMessage"
+                                }
+                            }
+                        },
+                    },
+                    "422": {
+                        "description": "Validation Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HTTPValidationError"
+                                }
+                            }
+                        },
+                    },
+                },
+            }
+        },
+    },
+    "components": {
+        "schemas": {
+            "HTTPValidationError": {
+                "title": "HTTPValidationError",
+                "type": "object",
+                "properties": {
+                    "detail": {
+                        "title": "Detail",
+                        "type": "array",
+                        "items": {"$ref": "#/components/schemas/ValidationError"},
+                    }
+                },
+            },
+            "Item": {
+                "title": "Item",
+                "required": ["name", "price"],
+                "type": "object",
+                "properties": {
+                    "name": {"title": "Name", "type": "string"},
+                    "price": {"title": "Price", "type": "number"},
+                },
+            },
+            "ResponseMessage": {
+                "title": "ResponseMessage",
+                "required": ["message"],
+                "type": "object",
+                "properties": {"message": {"title": "Message", "type": "string"}},
+            },
+            "User": {
+                "title": "User",
+                "required": ["username", "email"],
+                "type": "object",
+                "properties": {
+                    "username": {"title": "Username", "type": "string"},
+                    "email": {"title": "Email", "type": "string"},
+                },
+            },
+            "ValidationError": {
+                "title": "ValidationError",
+                "required": ["loc", "msg", "type"],
+                "type": "object",
+                "properties": {
+                    "loc": {
+                        "title": "Location",
+                        "type": "array",
+                        "items": {"type": "string"},
+                    },
+                    "msg": {"title": "Message", "type": "string"},
+                    "type": {"title": "Error Type", "type": "string"},
+                },
+            },
+        }
+    },
+}
+
+
+def test_openapi():
+    with client:
+        response = client.get("/openapi.json")
+
+        assert response.json() == openapi_schema
+
+
+def test_post_items():
+    response = client.post("/items/", json={"name": "Foo", "price": 5})
+    assert response.status_code == 200, response.text
+    assert response.json() == {"message": "Item received"}
+
+
+def test_post_users():
+    response = client.post(
+        "/users/", json={"username": "Foo", "email": "foo@example.com"}
+    )
+    assert response.status_code == 200, response.text
+    assert response.json() == {"message": "User received"}
+
+
+def test_get_items():
+    response = client.get("/items/")
+    assert response.status_code == 200, response.text
+    assert response.json() == [
+        {"name": "Plumbus", "price": 3},
+        {"name": "Portal Gun", "price": 9001},
+    ]