committed by
Sebastián Ramírez
16 changed files with 503 additions and 38 deletions
After Width: | Height: | Size: 97 KiB |
@ -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"} |
@ -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"> |
@ -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({}) |
Loading…
Reference in new issue