diff --git a/docs/img/tutorial/openapi-callbacks/image01.png b/docs/img/tutorial/openapi-callbacks/image01.png new file mode 100644 index 000000000..45e6366ab 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 index 000000000..b2838b0a2 --- /dev/null +++ b/docs/src/openapi_callbacks/tutorial001.py @@ -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 index 000000000..e48ee7f95 --- /dev/null +++ b/docs/tutorial/openapi-callbacks.md @@ -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 URL 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 HTTPX or Requests. + +## 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 OpenAPI 3 expression (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 OpenAPI 3 expression 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 http://127.0.0.1:8000/docs. + +You will see your docs including a "Callback" section for your *path operation* that shows how the *external API* should look like: + + diff --git a/fastapi/applications.py b/fastapi/applications.py index ab1b77e6d..5a533866b 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -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, ) diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index d2dd62081..d53ee6b97 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -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( diff --git a/fastapi/routing.py b/fastapi/routing.py index 8f4c67ca5..ee75374b4 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -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, ) diff --git a/fastapi/utils.py b/fastapi/utils.py index 5e624f0ea..a068cc582 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index d458366a7..541afc8e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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' diff --git a/tests/test_application.py b/tests/test_application.py index bdbff1cf6..11f463336 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -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, diff --git a/tests/test_extra_routes.py b/tests/test_extra_routes.py index f5bd7b3f2..bfe79d77f 100644 --- a/tests/test_extra_routes.py +++ b/tests/test_extra_routes.py @@ -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, diff --git a/tests/test_starlette_exception.py b/tests/test_starlette_exception.py index 706957b99..bafa31835 100644 --- a/tests/test_starlette_exception.py +++ b/tests/test_starlette_exception.py @@ -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, diff --git a/tests/test_tutorial/test_body_nested_models/test_tutorial009.py b/tests/test_tutorial/test_body_nested_models/test_tutorial009.py index e88e7a94a..d51b07252 100644 --- a/tests/test_tutorial/test_body_nested_models/test_tutorial009.py +++ b/tests/test_tutorial/test_body_nested_models/test_tutorial009.py @@ -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": { diff --git a/tests/test_tutorial/test_extra_models/test_tutorial005.py b/tests/test_tutorial/test_extra_models/test_tutorial005.py index 7f815e08a..935debf92 100644 --- a/tests/test_tutorial/test_extra_models/test_tutorial005.py +++ b/tests/test_tutorial/test_extra_models/test_tutorial005.py @@ -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", } } }, diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial002.py b/tests/test_tutorial/test_handling_errors/test_tutorial002.py index 3381f9d72..e0aa05a15 100644 --- a/tests/test_tutorial/test_handling_errors/test_tutorial002.py +++ b/tests/test_tutorial/test_handling_errors/test_tutorial002.py @@ -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 index 000000000..e69de29bb 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 index 000000000..febbe7479 --- /dev/null +++ b/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py @@ -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({})