]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
:sparkles: Add support for OpenAPI Callbacks (#722)
authorBen Dayan <booooh@users.noreply.github.com>
Wed, 11 Dec 2019 16:58:01 +0000 (18:58 +0200)
committerSebastián Ramírez <tiangolo@gmail.com>
Wed, 11 Dec 2019 16:58:00 +0000 (17:58 +0100)
16 files changed:
docs/img/tutorial/openapi-callbacks/image01.png [new file with mode: 0644]
docs/src/openapi_callbacks/tutorial001.py [new file with mode: 0644]
docs/tutorial/openapi-callbacks.md [new file with mode: 0644]
fastapi/applications.py
fastapi/openapi/utils.py
fastapi/routing.py
fastapi/utils.py
mkdocs.yml
tests/test_application.py
tests/test_extra_routes.py
tests/test_starlette_exception.py
tests/test_tutorial/test_body_nested_models/test_tutorial009.py
tests/test_tutorial/test_extra_models/test_tutorial005.py
tests/test_tutorial/test_handling_errors/test_tutorial002.py
tests/test_tutorial/test_openapi_callbacks/__init__.py [new file with mode: 0644]
tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py [new file with mode: 0644]

diff --git a/docs/img/tutorial/openapi-callbacks/image01.png b/docs/img/tutorial/openapi-callbacks/image01.png
new file mode 100644 (file)
index 0000000..45e6366
Binary files /dev/null and b/docs/img/tutorial/openapi-callbacks/image01.png differ
diff --git a/docs/src/openapi_callbacks/tutorial001.py b/docs/src/openapi_callbacks/tutorial001.py
new file mode 100644 (file)
index 0000000..b2838b0
--- /dev/null
@@ -0,0 +1,53 @@
+from fastapi import APIRouter, FastAPI
+from pydantic import BaseModel, HttpUrl
+from starlette.responses import JSONResponse
+
+app = FastAPI()
+
+
+class Invoice(BaseModel):
+    id: str
+    title: str = None
+    customer: str
+    total: float
+
+
+class InvoiceEvent(BaseModel):
+    description: str
+    paid: bool
+
+
+class InvoiceEventReceived(BaseModel):
+    ok: bool
+
+
+invoices_callback_router = APIRouter(default_response_class=JSONResponse)
+
+
+@invoices_callback_router.post(
+    "{$callback_url}/invoices/{$request.body.id}",
+    response_model=InvoiceEventReceived,
+)
+def invoice_notification(body: InvoiceEvent):
+    pass
+
+
+@app.post("/invoices/", callbacks=invoices_callback_router.routes)
+def create_invoice(invoice: Invoice, callback_url: HttpUrl = None):
+    """
+    Create an invoice.
+
+    This will (let's imagine) let the API user (some external developer) create an
+    invoice.
+
+    And this path operation will:
+
+    * Send the invoice to the client.
+    * Collect the money from the client.
+    * Send a notification back to the API user (the external developer), as a callback.
+        * At this point is that the API will somehow send a POST request to the
+            external API with the notification of the invoice event
+            (e.g. "payment successful").
+    """
+    # Send the invoice, collect the money, send the notification (the callback)
+    return {"msg": "Invoice received"}
diff --git a/docs/tutorial/openapi-callbacks.md b/docs/tutorial/openapi-callbacks.md
new file mode 100644 (file)
index 0000000..e48ee7f
--- /dev/null
@@ -0,0 +1,186 @@
+You could create an API with a *path operation* that could trigger a request to an *external API* created by someone else (probably the same developer that would be *using* your API).
+
+The process that happens when your API app calls the *external API* is named a "callback". Because the software that the external developer wrote sends a request to your API and then your API *calls back*, sending a request to an *external API* (that was probably created by the same developer).
+
+In this case, you could want to document how that external API *should* look like. What *path operation* it should have, what body it should expect, what response it should return, etc.
+
+## An app with callbacks
+
+Let's see all this with an example.
+
+Imagine you develop an app that allows creating invoices.
+
+These invoices will have an `id`, `title` (optional), `customer`, and `total`.
+
+The user of your API (an external developer) will create an invoice in your API with a POST request.
+
+Then your API will (let's imagine):
+
+* Send the invoice to some customer of the external developer.
+* Collect the money.
+* Send a notification back to the API user (the external developer).
+    * This will be done by sending a POST request (from *your API*) to some *external API* provided by that external developer (this is the "callback").
+
+## The normal **FastAPI** app
+
+Let's first see how the normal API app would look like before adding the callback.
+
+It will have a *path operation* that will receive an `Invoice` body, and a query parameter `callback_url` that will contain the URL for the callback.
+
+This part is pretty normal, most of the code is probably already familiar to you:
+
+```Python hl_lines="8 9 10 11 12  35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54"
+{!./src/openapi_callbacks/tutorial001.py!}
+```
+
+!!! tip
+    The `callback_url` query parameter uses a Pydantic <a href="https://pydantic-docs.helpmanual.io/usage/types/#urls" target="_blank">URL</a> type.
+
+The only new thing is the `callbacks=messages_callback_router.routes` as an argument to the *path operation decorator*. We'll see what that is next.
+
+## Documenting the callback
+
+The actual callback code will depend heavily on your own API app.
+
+And it will probably vary a lot from one app to the next.
+
+It could be just one or two lines of code, like:
+
+```Python
+callback_url = "https://example.com/api/v1/invoices/events/"
+requests.post(callback_url, json={"description": "Invoice paid", "paid": True})
+```
+
+But possibly the most important part of the callback is making sure that your API user (the external developer) implements the *external API* correctly, according to the data that *your API* is going to send in the request body of the callback, etc.
+
+So, what we will do next is add the code to document how that *external API* should look like to receive the callback from *your API*.
+
+That documentation will show up in the Swagger UI at `/docs` in your API, and it will let external developers know how to build the *external API*.
+
+This example doesn't implement the callback itself (that could be just a line of code), only the documentation part.
+
+!!! tip
+    The actual callback is just an HTTP request.
+
+    When implementing the callback yourself, you could use something like <a href="https://www.encode.io/httpx/" target="_blank">HTTPX</a> or <a href="https://requests.readthedocs.io/" target="_blank">Requests</a>.
+
+## Write the callback documentation code
+
+This code won't be executed in your app, we only need it to *document* how that *external API* should look like.
+
+But, you already know how to easily create automatic documentation for an API with **FastAPI**.
+
+So we are going to use that same knowledge to document how the *external API* should look like... by creating the *path operation(s)* that the external API should implement (the ones your API will call).
+
+!!! tip
+    When writing the code to document a callback, it might be useful to imagine that you are that *external developer*. And that you are currently implementing the *external API*, not *your API*.
+
+    Temporarily adopting this point of view (of the *external developer*) can help you feel like it's more obvious where to put the parameters, the Pydantic model for the body, for the response, etc. for that *external API*.
+
+### Create a callback `APIRouter`
+
+First create a new `APIRouter` that will contain one or more callbacks.
+
+This router will never be added to an actual `FastAPI` app (i.e. it will never be passed to `app.include_router(...)`).
+
+Because of that, you need to declare what will be the `default_response_class`, and set it to `JSONResponse`.
+
+!!! Note "Technical Details"
+    The `response_class` is normally set by the `FastAPI` app during the call to `app.include_router(some_router)`.
+
+    But as we are never calling `app.include_router(some_router)`, we need to set the `default_response_class` during creation of the `APIRouter`.
+
+```Python hl_lines="3 24"
+{!./src/openapi_callbacks/tutorial001.py!}
+```
+
+### Create the callback *path operation*
+
+To create the callback *path operation* use the same `APIRouter` you created above.
+
+It should look just like a normal FastAPI *path operation*:
+
+* It should probably have a declaration of the body it should receive, e.g. `body: InvoiceEvent`.
+* And it could also have a declaration of the response it should return, e.g. `response_model=InvoiceEventReceived`.
+
+```Python hl_lines="15 16 17  20 21  27 28 29 30 31 32"
+{!./src/openapi_callbacks/tutorial001.py!}
+```
+
+There are 2 main differences from a normal *path operation*:
+
+* It doesn't need to have any actual code, because your app will never call this code. It's only used to document the *external API*. So, the function could just have `pass`.
+* The *path* can contain an <a href="https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#key-expression" target="_blank">OpenAPI 3 expression</a> (see more below) where it can use variables with parameters and parts of the original request sent to *your API*.
+
+### The callback path expression
+
+The callback *path* can have an <a href="https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#key-expression" target="_blank">OpenAPI 3 expression</a> that can contain parts of the original request sent to *your API*.
+
+In this case, it's the `str`:
+
+```Python
+"{$callback_url}/invoices/{$request.body.id}"
+```
+
+So, if your API user (the external developer) sends a request to *your API* to:
+
+```
+https://yourapi.com/invoices/?callback_url=https://www.external.org/events
+```
+
+with a JSON body of:
+
+```JSON
+{
+    "id": "2expen51ve",
+    "customer": "Mr. Richie Rich",
+    "total": "9999"
+}
+```
+
+Then *your API* will process the invoice, and at some point later, send a callback request to the `callback_url` (the *external API*):
+
+```
+https://www.external.org/events/invoices/2expen51ve
+```
+
+with a JSON body containing something like:
+
+```JSON
+{
+    "description": "Payment celebration",
+    "paid": true
+}
+```
+
+and it would expect a response from that *external API* with a JSON body like:
+
+```JSON
+{
+    "ok": true
+}
+```
+
+!!! tip
+    Notice how the callback URL used contains the URL received as a query parameter in `callback_url` (`https://www.external.org/events`) and also the invoice `id` from inside of the JSON body (`2expen51ve`).
+
+### Add the callback router
+
+At this point you have the *callback path operation(s)* needed (the one(s) that the *external developer*  should implement in the *external API*) in the callback router you created above.
+
+Now use the parameter `callbacks` in *your API's path operation decorator* to pass the attribute `.routes` (that's actually just a `list` of routes/*path operations*) from that callback router:
+
+```Python hl_lines="35"
+{!./src/openapi_callbacks/tutorial001.py!}
+```
+
+!!! tip
+    Notice that you are not passing the router itself (`invoices_callback_router`) to `callback=`, but the attribute `.routes`, as in `invoices_callback_router.routes`.
+
+### Check the docs
+
+Now you can start your app with Uvicorn and go to <a href="http://127.0.0.1:8000/docs" target="_blank">http://127.0.0.1:8000/docs</a>.
+
+You will see your docs including a "Callback" section for your *path operation* that shows how the *external API* should look like:
+
+<img src="/img/tutorial/openapi-callbacks/image01.png">
index ab1b77e6dcc4b6e56aba7043a79e24a715527357..5a533866b4075bd9c1b5781a29a2b52ffa586ed6 100644 (file)
@@ -303,6 +303,7 @@ class FastAPI(Starlette):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[routing.APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -327,6 +328,7 @@ class FastAPI(Starlette):
             include_in_schema=include_in_schema,
             response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def put(
@@ -351,6 +353,7 @@ class FastAPI(Starlette):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[routing.APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -375,6 +378,7 @@ class FastAPI(Starlette):
             include_in_schema=include_in_schema,
             response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def post(
@@ -399,6 +403,7 @@ class FastAPI(Starlette):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[routing.APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -423,6 +428,7 @@ class FastAPI(Starlette):
             include_in_schema=include_in_schema,
             response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def delete(
@@ -447,6 +453,7 @@ class FastAPI(Starlette):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[routing.APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -471,6 +478,7 @@ class FastAPI(Starlette):
             include_in_schema=include_in_schema,
             response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def options(
@@ -495,6 +503,7 @@ class FastAPI(Starlette):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[routing.APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -519,6 +528,7 @@ class FastAPI(Starlette):
             include_in_schema=include_in_schema,
             response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def head(
@@ -543,6 +553,7 @@ class FastAPI(Starlette):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[routing.APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -567,6 +578,7 @@ class FastAPI(Starlette):
             include_in_schema=include_in_schema,
             response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def patch(
@@ -591,6 +603,7 @@ class FastAPI(Starlette):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[routing.APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -615,6 +628,7 @@ class FastAPI(Starlette):
             include_in_schema=include_in_schema,
             response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def trace(
@@ -639,6 +653,7 @@ class FastAPI(Starlette):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[routing.APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -663,4 +678,5 @@ class FastAPI(Starlette):
             include_in_schema=include_in_schema,
             response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
index d2dd620813e417c49644f45ab9ddbcfff15b2671..d53ee6b97b736bf8bd0ced4b6aa741859ca62779 100644 (file)
@@ -187,6 +187,14 @@ def get_openapi_path(
                 )
                 if request_body_oai:
                     operation["requestBody"] = request_body_oai
+            if route.callbacks:
+                callbacks = {}
+                for callback in route.callbacks:
+                    cb_path, cb_security_schemes, cb_definitions, = get_openapi_path(
+                        route=callback, model_name_map=model_name_map
+                    )
+                    callbacks[callback.name] = {callback.path: cb_path}
+                operation["callbacks"] = callbacks
             if route.responses:
                 for (additional_status_code, response) in route.responses.items():
                     assert isinstance(
index 8f4c67ca5de928b4353048e9aeeac06e6a338fa6..ee75374b4e0680e5033aa3c82c86ed4c37bf4102 100644 (file)
@@ -218,6 +218,7 @@ class APIRoute(routing.Route):
         include_in_schema: bool = True,
         response_class: Optional[Type[Response]] = None,
         dependency_overrides_provider: Any = None,
+        callbacks: Optional[List["APIRoute"]] = None,
     ) -> None:
         self.path = path
         self.endpoint = endpoint
@@ -338,6 +339,7 @@ class APIRoute(routing.Route):
             )
         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())
 
     def get_route_handler(self) -> Callable:
@@ -363,12 +365,14 @@ class APIRouter(routing.Router):
         default: ASGIApp = None,
         dependency_overrides_provider: Any = None,
         route_class: Type[APIRoute] = APIRoute,
+        default_response_class: Type[Response] = None,
     ) -> None:
         super().__init__(
             routes=routes, redirect_slashes=redirect_slashes, default=default
         )
         self.dependency_overrides_provider = dependency_overrides_provider
         self.route_class = route_class
+        self.default_response_class = default_response_class
 
     def add_api_route(
         self,
@@ -395,6 +399,7 @@ class APIRouter(routing.Router):
         response_class: Type[Response] = None,
         name: str = None,
         route_class_override: Optional[Type[APIRoute]] = None,
+        callbacks: List[APIRoute] = None,
     ) -> None:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -420,9 +425,10 @@ class APIRouter(routing.Router):
                 response_model_exclude_unset or response_model_skip_defaults
             ),
             include_in_schema=include_in_schema,
-            response_class=response_class,
+            response_class=response_class or self.default_response_class,
             name=name,
             dependency_overrides_provider=self.dependency_overrides_provider,
+            callbacks=callbacks,
         )
         self.routes.append(route)
 
@@ -449,6 +455,7 @@ class APIRouter(routing.Router):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -475,8 +482,9 @@ class APIRouter(routing.Router):
                     response_model_exclude_unset or response_model_skip_defaults
                 ),
                 include_in_schema=include_in_schema,
-                response_class=response_class,
+                response_class=response_class or self.default_response_class,
                 name=name,
+                callbacks=callbacks,
             )
             return func
 
@@ -586,6 +594,7 @@ class APIRouter(routing.Router):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -609,8 +618,9 @@ class APIRouter(routing.Router):
                 response_model_exclude_unset or response_model_skip_defaults
             ),
             include_in_schema=include_in_schema,
-            response_class=response_class,
+            response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def put(
@@ -635,6 +645,7 @@ class APIRouter(routing.Router):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -658,8 +669,9 @@ class APIRouter(routing.Router):
                 response_model_exclude_unset or response_model_skip_defaults
             ),
             include_in_schema=include_in_schema,
-            response_class=response_class,
+            response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def post(
@@ -684,6 +696,7 @@ class APIRouter(routing.Router):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -707,8 +720,9 @@ class APIRouter(routing.Router):
                 response_model_exclude_unset or response_model_skip_defaults
             ),
             include_in_schema=include_in_schema,
-            response_class=response_class,
+            response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def delete(
@@ -733,6 +747,7 @@ class APIRouter(routing.Router):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -756,8 +771,9 @@ class APIRouter(routing.Router):
                 response_model_exclude_unset or response_model_skip_defaults
             ),
             include_in_schema=include_in_schema,
-            response_class=response_class,
+            response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def options(
@@ -782,6 +798,7 @@ class APIRouter(routing.Router):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -805,8 +822,9 @@ class APIRouter(routing.Router):
                 response_model_exclude_unset or response_model_skip_defaults
             ),
             include_in_schema=include_in_schema,
-            response_class=response_class,
+            response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def head(
@@ -831,6 +849,7 @@ class APIRouter(routing.Router):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -854,8 +873,9 @@ class APIRouter(routing.Router):
                 response_model_exclude_unset or response_model_skip_defaults
             ),
             include_in_schema=include_in_schema,
-            response_class=response_class,
+            response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def patch(
@@ -880,6 +900,7 @@ class APIRouter(routing.Router):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -903,8 +924,9 @@ class APIRouter(routing.Router):
                 response_model_exclude_unset or response_model_skip_defaults
             ),
             include_in_schema=include_in_schema,
-            response_class=response_class,
+            response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
 
     def trace(
@@ -929,6 +951,7 @@ class APIRouter(routing.Router):
         include_in_schema: bool = True,
         response_class: Type[Response] = None,
         name: str = None,
+        callbacks: List[APIRoute] = None,
     ) -> Callable:
         if response_model_skip_defaults is not None:
             warning_response_model_skip_defaults_deprecated()  # pragma: nocover
@@ -952,6 +975,7 @@ class APIRouter(routing.Router):
                 response_model_exclude_unset or response_model_skip_defaults
             ),
             include_in_schema=include_in_schema,
-            response_class=response_class,
+            response_class=response_class or self.default_response_class,
             name=name,
+            callbacks=callbacks,
         )
index 5e624f0ea28e40b5b5e9ae443ec8ff25e47aadac..a068cc582307b6566d143f7be4a5f0e2cc359e26 100644 (file)
@@ -46,6 +46,7 @@ def warning_response_model_skip_defaults_deprecated() -> None:
 def get_flat_models_from_routes(routes: Sequence[BaseRoute]) -> Set[Type[BaseModel]]:
     body_fields_from_routes: List[ModelField] = []
     responses_from_routes: List[ModelField] = []
+    callback_flat_models: Set[Type[BaseModel]] = set()
     for route in routes:
         if getattr(route, "include_in_schema", None) and isinstance(
             route, routing.APIRoute
@@ -59,7 +60,9 @@ def get_flat_models_from_routes(routes: Sequence[BaseRoute]) -> Set[Type[BaseMod
                 responses_from_routes.append(route.response_field)
             if route.response_fields:
                 responses_from_routes.extend(route.response_fields.values())
-    flat_models = get_flat_models_from_fields(
+            if route.callbacks:
+                callback_flat_models |= get_flat_models_from_routes(route.callbacks)
+    flat_models = callback_flat_models | get_flat_models_from_fields(
         body_fields_from_routes + responses_from_routes, known_models=set()
     )
     return flat_models
@@ -153,6 +156,6 @@ def create_cloned_field(field: ModelField) -> ModelField:
 
 def generate_operation_id_for_path(*, name: str, path: str, method: str) -> str:
     operation_id = name + path
-    operation_id = operation_id.replace("{", "_").replace("}", "_").replace("/", "_")
+    operation_id = re.sub("[^0-9a-zA-Z_]", "_", operation_id)
     operation_id = operation_id + "_" + method.lower()
     return operation_id
index d458366a76697822a6eaaccf7063cfb3d91cfaf3..541afc8e8a4018295c388d58be75f307f143d7b0 100644 (file)
@@ -88,6 +88,7 @@ nav:
         - Testing Dependencies with Overrides: 'tutorial/testing-dependencies.md'
         - Debugging: 'tutorial/debugging.md'
         - Extending OpenAPI: 'tutorial/extending-openapi.md'
+        - OpenAPI Callbacks: 'tutorial/openapi-callbacks.md'
     - Concurrency and async / await: 'async.md'
     - Deployment: 'deployment.md'
     - Project Generation - Template: 'project-generation.md'
index bdbff1cf6ff0d8078c471e4645c52662f10324f9..11f463336a0286224c52a834483d6a39e8ba5679 100644 (file)
@@ -244,7 +244,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Required Id",
-                "operationId": "get_path_param_required_id_path_param-required__item_id__get",
+                "operationId": "get_path_param_required_id_path_param_required__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -274,7 +274,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Min Length",
-                "operationId": "get_path_param_min_length_path_param-minlength__item_id__get",
+                "operationId": "get_path_param_min_length_path_param_minlength__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -308,7 +308,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Max Length",
-                "operationId": "get_path_param_max_length_path_param-maxlength__item_id__get",
+                "operationId": "get_path_param_max_length_path_param_maxlength__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -342,7 +342,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Min Max Length",
-                "operationId": "get_path_param_min_max_length_path_param-min_maxlength__item_id__get",
+                "operationId": "get_path_param_min_max_length_path_param_min_maxlength__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -377,7 +377,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Gt",
-                "operationId": "get_path_param_gt_path_param-gt__item_id__get",
+                "operationId": "get_path_param_gt_path_param_gt__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -411,7 +411,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Gt0",
-                "operationId": "get_path_param_gt0_path_param-gt0__item_id__get",
+                "operationId": "get_path_param_gt0_path_param_gt0__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -445,7 +445,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Ge",
-                "operationId": "get_path_param_ge_path_param-ge__item_id__get",
+                "operationId": "get_path_param_ge_path_param_ge__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -479,7 +479,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Lt",
-                "operationId": "get_path_param_lt_path_param-lt__item_id__get",
+                "operationId": "get_path_param_lt_path_param_lt__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -513,7 +513,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Lt0",
-                "operationId": "get_path_param_lt0_path_param-lt0__item_id__get",
+                "operationId": "get_path_param_lt0_path_param_lt0__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -547,7 +547,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Le",
-                "operationId": "get_path_param_le_path_param-le__item_id__get",
+                "operationId": "get_path_param_le_path_param_le__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -581,7 +581,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Lt Gt",
-                "operationId": "get_path_param_lt_gt_path_param-lt-gt__item_id__get",
+                "operationId": "get_path_param_lt_gt_path_param_lt_gt__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -616,7 +616,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Le Ge",
-                "operationId": "get_path_param_le_ge_path_param-le-ge__item_id__get",
+                "operationId": "get_path_param_le_ge_path_param_le_ge__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -651,7 +651,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Lt Int",
-                "operationId": "get_path_param_lt_int_path_param-lt-int__item_id__get",
+                "operationId": "get_path_param_lt_int_path_param_lt_int__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -685,7 +685,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Gt Int",
-                "operationId": "get_path_param_gt_int_path_param-gt-int__item_id__get",
+                "operationId": "get_path_param_gt_int_path_param_gt_int__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -719,7 +719,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Le Int",
-                "operationId": "get_path_param_le_int_path_param-le-int__item_id__get",
+                "operationId": "get_path_param_le_int_path_param_le_int__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -753,7 +753,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Ge Int",
-                "operationId": "get_path_param_ge_int_path_param-ge-int__item_id__get",
+                "operationId": "get_path_param_ge_int_path_param_ge_int__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -787,7 +787,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Lt Gt Int",
-                "operationId": "get_path_param_lt_gt_int_path_param-lt-gt-int__item_id__get",
+                "operationId": "get_path_param_lt_gt_int_path_param_lt_gt_int__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -822,7 +822,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Path Param Le Ge Int",
-                "operationId": "get_path_param_le_ge_int_path_param-le-ge-int__item_id__get",
+                "operationId": "get_path_param_le_ge_int_path_param_le_ge_int__item_id__get",
                 "parameters": [
                     {
                         "required": True,
@@ -1037,7 +1037,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Query Param Required",
-                "operationId": "get_query_param_required_query_param-required_get",
+                "operationId": "get_query_param_required_query_param_required_get",
                 "parameters": [
                     {
                         "required": True,
@@ -1067,7 +1067,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Query Param Required Type",
-                "operationId": "get_query_param_required_type_query_param-required_int_get",
+                "operationId": "get_query_param_required_type_query_param_required_int_get",
                 "parameters": [
                     {
                         "required": True,
index f5bd7b3f20e71113b7fba0c622f2dcfb0f2b922d..bfe79d77f99f90f2a8b619313fa30d67ec33e12e 100644 (file)
@@ -259,7 +259,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Get Not Decorated",
-                "operationId": "get_not_decorated_items-not-decorated__item_id__get",
+                "operationId": "get_not_decorated_items_not_decorated__item_id__get",
                 "parameters": [
                     {
                         "required": True,
index 706957b9913d3f15f4f1c932bd19aa237d0dbecc..bafa3183576a8ae8b69275511b11bbaa93f5c5b3 100644 (file)
@@ -80,7 +80,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Create Item",
-                "operationId": "create_item_starlette-items__item_id__get",
+                "operationId": "create_item_starlette_items__item_id__get",
                 "parameters": [
                     {
                         "required": True,
index e88e7a94ad46700852ef65d3f300bde928d3dd77..d51b07252998bcc31aa46019ca62e2e084c034d8 100644 (file)
@@ -27,7 +27,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Create Index Weights",
-                "operationId": "create_index_weights_index-weights__post",
+                "operationId": "create_index_weights_index_weights__post",
                 "requestBody": {
                     "content": {
                         "application/json": {
index 7f815e08a74631b9d26c39954d2716f7d85c052e..935debf922a3c46f99f92ee723ade5e1ea07eb9e 100644 (file)
@@ -16,7 +16,7 @@ openapi_schema = {
                         "content": {
                             "application/json": {
                                 "schema": {
-                                    "title": "Response Read Keyword Weights Keyword-Weights  Get",
+                                    "title": "Response Read Keyword Weights Keyword Weights  Get",
                                     "type": "object",
                                     "additionalProperties": {"type": "number"},
                                 }
@@ -25,7 +25,7 @@ openapi_schema = {
                     }
                 },
                 "summary": "Read Keyword Weights",
-                "operationId": "read_keyword_weights_keyword-weights__get",
+                "operationId": "read_keyword_weights_keyword_weights__get",
             }
         }
     },
index 3381f9d72042e00b56dc3a56934cd8b77c21ecbd..e0aa05a1593287f099039f9130e29fe1019eee89 100644 (file)
@@ -27,7 +27,7 @@ openapi_schema = {
                     },
                 },
                 "summary": "Read Item Header",
-                "operationId": "read_item_header_items-header__item_id__get",
+                "operationId": "read_item_header_items_header__item_id__get",
                 "parameters": [
                     {
                         "required": True,
diff --git a/tests/test_tutorial/test_openapi_callbacks/__init__.py b/tests/test_tutorial/test_openapi_callbacks/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py b/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py
new file mode 100644 (file)
index 0000000..febbe74
--- /dev/null
@@ -0,0 +1,174 @@
+from starlette.testclient import TestClient
+
+from openapi_callbacks.tutorial001 import app, invoice_notification
+
+client = TestClient(app)
+
+openapi_schema = {
+    "openapi": "3.0.2",
+    "info": {"title": "Fast API", "version": "0.1.0"},
+    "paths": {
+        "/invoices/": {
+            "post": {
+                "summary": "Create Invoice",
+                "description": 'Create an invoice.\n\nThis will (let\'s imagine) let the API user (some external developer) create an\ninvoice.\n\nAnd this path operation will:\n\n* Send the invoice to the client.\n* Collect the money from the client.\n* Send a notification back to the API user (the external developer), as a callback.\n    * At this point is that the API will somehow send a POST request to the\n        external API with the notification of the invoice event\n        (e.g. "payment successful").',
+                "operationId": "create_invoice_invoices__post",
+                "parameters": [
+                    {
+                        "required": False,
+                        "schema": {
+                            "title": "Callback Url",
+                            "maxLength": 2083,
+                            "minLength": 1,
+                            "type": "string",
+                            "format": "uri",
+                        },
+                        "name": "callback_url",
+                        "in": "query",
+                    }
+                ],
+                "requestBody": {
+                    "content": {
+                        "application/json": {
+                            "schema": {"$ref": "#/components/schemas/Invoice"}
+                        }
+                    },
+                    "required": True,
+                },
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {"application/json": {"schema": {}}},
+                    },
+                    "422": {
+                        "description": "Validation Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HTTPValidationError"
+                                }
+                            }
+                        },
+                    },
+                },
+                "callbacks": {
+                    "invoice_notification": {
+                        "{$callback_url}/invoices/{$request.body.id}": {
+                            "post": {
+                                "summary": "Invoice Notification",
+                                "operationId": "invoice_notification__callback_url__invoices___request_body_id__post",
+                                "requestBody": {
+                                    "required": True,
+                                    "content": {
+                                        "application/json": {
+                                            "schema": {
+                                                "$ref": "#/components/schemas/InvoiceEvent"
+                                            }
+                                        }
+                                    },
+                                },
+                                "responses": {
+                                    "200": {
+                                        "description": "Successful Response",
+                                        "content": {
+                                            "application/json": {
+                                                "schema": {
+                                                    "$ref": "#/components/schemas/InvoiceEventReceived"
+                                                }
+                                            }
+                                        },
+                                    },
+                                    "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"},
+                    }
+                },
+            },
+            "Invoice": {
+                "title": "Invoice",
+                "required": ["id", "customer", "total"],
+                "type": "object",
+                "properties": {
+                    "id": {"title": "Id", "type": "string"},
+                    "title": {"title": "Title", "type": "string"},
+                    "customer": {"title": "Customer", "type": "string"},
+                    "total": {"title": "Total", "type": "number"},
+                },
+            },
+            "InvoiceEvent": {
+                "title": "InvoiceEvent",
+                "required": ["description", "paid"],
+                "type": "object",
+                "properties": {
+                    "description": {"title": "Description", "type": "string"},
+                    "paid": {"title": "Paid", "type": "boolean"},
+                },
+            },
+            "InvoiceEventReceived": {
+                "title": "InvoiceEventReceived",
+                "required": ["ok"],
+                "type": "object",
+                "properties": {"ok": {"title": "Ok", "type": "boolean"}},
+            },
+            "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_get():
+    response = client.post(
+        "/invoices/", json={"id": "fooinvoice", "customer": "John", "total": 5.3}
+    )
+    assert response.status_code == 200
+    assert response.json() == {"msg": "Invoice received"}
+
+
+def test_dummy_callback():
+    # Just for coverage
+    invoice_notification({})