Simple FastAPI Example¶
The following is a single module FastAPI app written as a demo of the Saleor SDK Python package. This should not be treated as a production ready application.
For the sake of time let's import everything that will be needed upfront, initialize the app and put a JWKS storage in place:
from fastapi import FastAPI, Request, Depends, Header
from pydantic_settings import BaseSettings
import httpx
from jwt.api_jwk import PyJWKSet
from saleor_sdk.schemas.manifest import Manifest, Webhook
from saleor_sdk.schemas.enums import Permission, WebhookAsyncEvents
from saleor_sdk.crypto.utils import decode_webook_payload, decode_jwt
from saleor_sdk.crypto.exceptions import JWKSKeyMissing
class Settings(BaseSettings):
debug: bool = False
settings = Settings(debug=True)
app = FastAPI(debug=settings.debug)
saleor_jwks = None
Declare the App manifest¶
@app.get("/api/manifest")
async def manifest(request: Request):
return Manifest(
id="simple-sdk-test-app",
version="0.0.0",
name="Simple SDK Test APP",
permissions=[Permission.MANAGE_ORDERS, "MANAGE_CHECKOUTS"],
app_url=str(request.url_for("app_config")),
token_target_url=str(request.url_for("register")),
webhooks=[
Webhook(
name="Order Handler",
async_events=[WebhookAsyncEvents.ORDER_CREATED, "ORDER_UPDATED"],
query="subscription { event { issuedAt issuingPrincipal { ... on App { id } ... on User { id } } ... on OrderCreated { order { id }} ... on OrderUpdated { order { id }}}}",
target_url=str(request.url_for("order_handler")),
is_active=True,
)
],
)
There are a few things to notice here:
- enums - the SDK will try to keep the definitions up-to-date with Saleor but there is no promise this will always meet your needs (time-wise), this is why you can use both, the definitions from
saleor_sdk.schemas.enums
and plain strings. request.url_for()
- Saleor needs a full URL to your endpoint, here we leverage FastAPI's resolver (be mindful about what your proxy is doing, i.e. it's easy to hide crucial information from FastAPI with Gunicorn)- Subscription queries - the payload that webhook is to carry.
More on Subscription queries¶
Since Saleor 3.2 (and 3.6 for synchronous events) one can define the payload that Saleor will send to an app - this allows you to define a payload that is meaningful to the app.
Saleor docs: Subscription Webhook Payloads
A formatted query from the example looks like this:
subscription {
event {
issuedAt
issuingPrincipal {
... on App {
id
}
... on User {
id
}
}
... on OrderCreated {
order {
id
}
}
... on OrderUpdated {
order {
id
}
}
}
}
and will result in the following payload being sent to the app:
{
"issuedAt": "2022-12-12T00:37:17.405467+00:00",
"issuingPrincipal": {
"id": "VXNlcjoyMDU5ODA1MTg0"
},
"order": {
"id": "T3JkZXI6MWFkNzZjOTctZDkxNy00NjRmLWIwNzUtOTljNzcwY2IzOWI4"
}
}
Define some commonly used dependencies¶
async def get_saleor_event(saleor_event: str = Header(..., alias="Saleor-Event")):
return saleor_event
async def get_saleor_domain(saleor_domain: str = Header(..., alias="Saleor-Domain")):
return saleor_domain
async def get_saleor_signature(
saleor_signature: str = Header(..., alias="Saleor-Signature")
):
return saleor_signature
JWKS and user JWT verification¶
There are currently two ways to verify the authenticity of a Saleor issued JWT: - online - by calling Saleor to check (deprecated by Saleor) - offline - by checking with Saleor's public keyset. This library only supports the offline method.
Saleor instances expose a /.well-known/jwks.json
endpoint which exposes the public part of the RSA key used to sign the JWTs.
With that we can verify if the incoming token is indeed coming straight from Saleor and if we can trust it's claims.
To do that we need a storage within the app's memory to hold on to the JWKS (JSON Web Key Set), in this example we are simply using a global variable.
The get_saleor_user
dependency ensures the JWKS was initialized (first request in the runtime), then validates the token. If the local JWKS is missing a key, a JWKSKeyMissing
error will be raised and an attempt to get a fresh one from Saleor will be made - this might happen when Saleor rotates the keys and starts signing JWTs with the new key.
async def fetch_jwks(saleor_domain):
async with httpx.AsyncClient() as client:
response = await client.get(f"https://{saleor_domain}/.well-known/jwks.json")
return response.content
async def get_saleor_user(
saleor_domain: str = Depends(get_saleor_domain),
saleor_token: str = Header(..., alias="Saleor-Token"),
):
global saleor_jwks
if not saleor_jwks:
saleor_jwks = PyJWKSet.from_json(await fetch_jwks(saleor_domain))
max_attempts = 1
while max_attempts:
try:
return decode_jwt(
jwt=saleor_token,
jwks=saleor_jwks,
)
except JWKSKeyMissing as exc:
if max_attempts:
saleor_jwks = PyJWKSet.from_json(await fetch_jwks(saleor_domain))
max_attempts -= 1
else:
raise
Here's how it roughly works in sequence
sequenceDiagram
actor USR as User
participant SAL as Saleor
participant APP as App
participant STO as JWKS Storage
USR ->> SAL: Here are my credentials, give me a JWT
SAL ->> USR: Here's your JWT
USR ->> APP: Give me that secret resource
APP ->> STO: I've got a JWT with kid=1 do you have something on that?
alt Storage does not have the kid=1
STO ->> APP: No I don't
APP ->> SAL: Fetch JWKS
SAL ->> APP: JWKS
APP ->> STO: Save JWKS
else
STO ->> APP: Yes, here is the public key
end
APP ->> APP: Verify the JWT signature
APP ->> USR: Here's the data
Webhook signature verification¶
A very similar process applies to webhook authenticity verification. Leveraging the public key pair issued by Saleor the signature of a JWS (a JWT with detached payload) that is sent with a webhook can be verified in an offline manner.
async def verify_webhook_signature(
request: Request,
saleor_domain: str = Depends(get_saleor_domain),
jws: str = Depends(get_saleor_signature),
):
global saleor_jwks
if not saleor_jwks:
saleor_jwks = PyJWKSet.from_json(await fetch_jwks(saleor_domain))
max_attempts = 1
while max_attempts:
try:
return decode_webook_payload(
jws=jws,
jwks=saleor_jwks,
webhook_payload=await request.body(),
)
except JWKSKeyMissing as exc:
if max_attempts:
saleor_jwks = PyJWKSet.from_json(await fetch_jwks(saleor_domain))
max_attempts -= 1
else:
raise
Finally define the app endpoints¶
You need the two endpoints required by Saleor's framework:
@app.get("/", name="app_config")
async def app_config():
return "OK"
@app.post("/api/register", name="register")
async def register():
return "OK"
app_config
- this is the endpoint that is expected to respond with HTML that will initialize a React App in the Saleor Dashboard (read more on @saleor/app-sdk)register
- this endpoint receives the app_token which needs to be persisted securely, it's used by the app to authenticate with Saleor
Further we define the order webhook handler which leverages our verify_webhook_signature
dependency.
@app.post("/api/webhook/order", name="order_handler")
async def order_handler(
request: Request,
_verify_webhook_signature=Depends(verify_webhook_signature),
event_type=Depends(get_saleor_event),
):
print(event_type) # Use in case you have one handler for many event types
print(await request.body())
return "OK"
And an additional endpoint that could be used by the Dashboard UI to for example change the app configuration
async def user_login(saleor_user=Depends(get_saleor_user)):
print(saleor_user)
return "OK"
Running the complete example¶
Install the examples
dependencies with:
hatch -e examples shell
Then navigate to the example and run it with uvicorn:
cd docs/examples/simple
uvicorn app:app --reload