Skip to content

Commit 88991b8

Browse files
committed
doc: factorize ETag management
1 parent e6540af commit 88991b8

File tree

3 files changed

+49
-46
lines changed

3 files changed

+49
-46
lines changed

doc/guides/_examples/django_example.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,19 @@
3737

3838
# -- setup-start --
3939
def scim_response(payload, status=HTTPStatus.OK):
40-
"""Build a Django response with the SCIM media type."""
41-
return HttpResponse(
40+
"""Build a Django response with the SCIM media type.
41+
42+
Automatically sets the ``ETag`` header from ``meta.version`` when present.
43+
"""
44+
response = HttpResponse(
4245
payload,
4346
status=status,
4447
content_type="application/scim+json",
4548
)
49+
meta = json.loads(payload).get("meta", {})
50+
if version := meta.get("version"):
51+
response["ETag"] = version
52+
return response
4653

4754

4855
def resource_location(request, app_record):
@@ -139,15 +146,13 @@ def get(self, request, app_record):
139146
return HttpResponse(status=HTTPStatus.NOT_MODIFIED)
140147

141148
scim_user = to_scim_user(app_record, resource_location(request, app_record))
142-
resp = scim_response(
149+
return scim_response(
143150
scim_user.model_dump_json(
144151
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
145152
attributes=req.attributes,
146153
excluded_attributes=req.excluded_attributes,
147154
)
148155
)
149-
resp["ETag"] = etag
150-
return resp
151156

152157
def delete(self, request, app_record):
153158
if resp := check_etag(app_record, request):
@@ -180,13 +185,11 @@ def put(self, request, app_record):
180185
response_user = to_scim_user(
181186
updated_record, resource_location(request, updated_record)
182187
)
183-
resp = scim_response(
188+
return scim_response(
184189
response_user.model_dump_json(
185190
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
186191
)
187192
)
188-
resp["ETag"] = make_etag(updated_record)
189-
return resp
190193

191194
def patch(self, request, app_record):
192195
if resp := check_etag(app_record, request):
@@ -208,11 +211,9 @@ def patch(self, request, app_record):
208211
except SCIMException as error:
209212
return scim_exception_error(error)
210213

211-
resp = scim_response(
214+
return scim_response(
212215
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE)
213216
)
214-
resp["ETag"] = make_etag(updated_record)
215-
return resp
216217
# -- single-resource-end --
217218

218219

@@ -261,12 +262,10 @@ def post(self, request):
261262
return scim_exception_error(error)
262263

263264
response_user = to_scim_user(app_record, resource_location(request, app_record))
264-
resp = scim_response(
265+
return scim_response(
265266
response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE),
266267
HTTPStatus.CREATED,
267268
)
268-
resp["ETag"] = make_etag(app_record)
269-
return resp
270269

271270

272271
urlpatterns = [

doc/guides/_examples/fastapi_example.py

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from http import HTTPStatus
23

34
from fastapi import APIRouter
@@ -34,15 +35,21 @@
3435

3536
# -- setup-start --
3637
app = FastAPI()
37-
router = APIRouter(prefix="/scim/v2")
3838

3939

40-
@app.middleware("http")
41-
async def add_scim_content_type(request: Request, call_next):
42-
"""Set the SCIM media type on every response."""
43-
response = await call_next(request)
44-
response.headers["Content-Type"] = "application/scim+json"
45-
return response
40+
class SCIMResponse(Response):
41+
"""SCIM JSON response that auto-extracts the ``ETag`` from ``meta.version``."""
42+
43+
media_type = "application/scim+json"
44+
45+
def __init__(self, content: str, **kwargs):
46+
super().__init__(content=content, **kwargs)
47+
meta = json.loads(content).get("meta", {})
48+
if version := meta.get("version"):
49+
self.headers["ETag"] = version
50+
51+
52+
router = APIRouter(prefix="/scim/v2", default_response_class=SCIMResponse)
4653

4754

4855
def resource_location(request, app_record):
@@ -87,21 +94,21 @@ def resolve_user(user_id: str):
8794
async def handle_validation_error(request, error):
8895
"""Turn Pydantic validation errors into SCIM error responses."""
8996
scim_error = Error.from_validation_error(error.errors()[0])
90-
return Response(scim_error.model_dump_json(), status_code=scim_error.status)
97+
return SCIMResponse(scim_error.model_dump_json(), status_code=scim_error.status)
9198

9299

93100
@app.exception_handler(HTTPException)
94101
async def handle_http_exception(request, error):
95102
"""Turn HTTP exceptions into SCIM error responses."""
96103
scim_error = Error(status=error.status_code, detail=error.detail or "")
97-
return Response(scim_error.model_dump_json(), status_code=error.status_code)
104+
return SCIMResponse(scim_error.model_dump_json(), status_code=error.status_code)
98105

99106

100107
@app.exception_handler(SCIMException)
101108
async def handle_scim_error(request, error):
102109
"""Turn SCIM exceptions into SCIM error responses."""
103110
scim_error = error.to_error()
104-
return Response(scim_error.model_dump_json(), status_code=scim_error.status)
111+
return SCIMResponse(scim_error.model_dump_json(), status_code=scim_error.status)
105112
# -- error-handlers-end --
106113
# -- refinements-end --
107114

@@ -118,13 +125,12 @@ async def get_user(request: Request, app_record: dict = Depends(resolve_user)):
118125
if_none_match = request.headers.get("If-None-Match")
119126
if if_none_match and etag in [t.strip() for t in if_none_match.split(",")]:
120127
return Response(status_code=HTTPStatus.NOT_MODIFIED)
121-
return Response(
128+
return SCIMResponse(
122129
scim_user.model_dump_json(
123130
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
124131
attributes=req.attributes,
125132
excluded_attributes=req.excluded_attributes,
126133
),
127-
headers={"ETag": etag},
128134
)
129135
# -- get-user-end --
130136

@@ -144,9 +150,8 @@ async def patch_user(request: Request, app_record: dict = Depends(resolve_user))
144150
updated_record = from_scim_user(scim_user)
145151
save_record(updated_record)
146152

147-
return Response(
153+
return SCIMResponse(
148154
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE),
149-
headers={"ETag": make_etag(updated_record)},
150155
)
151156
# -- patch-user-end --
152157

@@ -170,9 +175,8 @@ async def replace_user(request: Request, app_record: dict = Depends(resolve_user
170175
response_user = to_scim_user(
171176
updated_record, resource_location(request, updated_record)
172177
)
173-
return Response(
178+
return SCIMResponse(
174179
response_user.model_dump_json(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE),
175-
headers={"ETag": make_etag(updated_record)},
176180
)
177181
# -- put-user-end --
178182

@@ -204,7 +208,7 @@ async def list_users(request: Request):
204208
items_per_page=len(resources),
205209
resources=resources,
206210
)
207-
return Response(
211+
return SCIMResponse(
208212
response.model_dump_json(
209213
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
210214
attributes=req.attributes,
@@ -226,10 +230,9 @@ async def create_user(request: Request):
226230
save_record(app_record)
227231

228232
response_user = to_scim_user(app_record, resource_location(request, app_record))
229-
return Response(
233+
return SCIMResponse(
230234
response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE),
231235
status_code=HTTPStatus.CREATED,
232-
headers={"ETag": make_etag(app_record)},
233236
)
234237
# -- create-user-end --
235238
# -- collection-end --
@@ -248,7 +251,7 @@ async def list_schemas(request: Request):
248251
items_per_page=len(page),
249252
resources=page,
250253
)
251-
return Response(
254+
return SCIMResponse(
252255
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
253256
)
254257

@@ -260,8 +263,8 @@ async def get_schema_by_id(schema_id: str):
260263
schema = get_schema(schema_id)
261264
except KeyError:
262265
scim_error = Error(status=404, detail=f"Schema {schema_id!r} not found")
263-
return Response(scim_error.model_dump_json(), status_code=HTTPStatus.NOT_FOUND)
264-
return Response(
266+
return SCIMResponse(scim_error.model_dump_json(), status_code=HTTPStatus.NOT_FOUND)
267+
return SCIMResponse(
265268
schema.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
266269
)
267270
# -- schemas-end --
@@ -279,7 +282,7 @@ async def list_resource_types(request: Request):
279282
items_per_page=len(page),
280283
resources=page,
281284
)
282-
return Response(
285+
return SCIMResponse(
283286
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
284287
)
285288

@@ -293,8 +296,8 @@ async def get_resource_type_by_id(resource_type_id: str):
293296
scim_error = Error(
294297
status=404, detail=f"ResourceType {resource_type_id!r} not found"
295298
)
296-
return Response(scim_error.model_dump_json(), status_code=HTTPStatus.NOT_FOUND)
297-
return Response(
299+
return SCIMResponse(scim_error.model_dump_json(), status_code=HTTPStatus.NOT_FOUND)
300+
return SCIMResponse(
298301
rt.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
299302
)
300303
# -- resource-types-end --
@@ -304,7 +307,7 @@ async def get_resource_type_by_id(resource_type_id: str):
304307
@router.get("/ServiceProviderConfig")
305308
async def get_service_provider_config():
306309
"""Return the SCIM service provider configuration."""
307-
return Response(
310+
return SCIMResponse(
308311
service_provider_config.model_dump_json(
309312
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
310313
),

doc/guides/fastapi.rst

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ Application setup
2525

2626
Start with a FastAPI application and an
2727
`APIRouter <https://fastapi.tiangolo.com/reference/apirouter/>`_ prefixed with ``/scim/v2``.
28-
The SCIM specifications indicates that the responses content type must be ``application/scim+json``,
29-
so lets enforce this on all routes with an
30-
`HTTP middleware <https://fastapi.tiangolo.com/tutorial/middleware/>`_.
28+
``SCIMResponse`` is a thin :class:`~fastapi.Response` subclass that sets the
29+
``application/scim+json`` content type and automatically extracts the ``ETag`` header
30+
from ``meta.version`` when the response body contains it.
3131

3232
.. literalinclude:: _examples/fastapi_example.py
3333
:language: python
@@ -180,15 +180,16 @@ the record's ETag and raises an :class:`~fastapi.HTTPException` on mismatch.
180180
:start-after: # -- etag-start --
181181
:end-before: # -- etag-end --
182182

183-
On ``GET`` single-resource responses, the ``ETag`` header is set and the ``If-None-Match``
183+
On ``GET`` single-resource responses, the ``If-None-Match``
184184
request header is checked manually to return a ``304 Not Modified`` when the client already
185185
has the current version.
186186

187187
On write operations (``PUT``, ``PATCH``, ``DELETE``), the ``If-Match`` header is checked
188188
before processing.
189189
If the client's ETag does not match, a ``412 Precondition Failed`` SCIM error is returned.
190-
``POST`` and ``PUT``/``PATCH`` responses include the ``ETag`` header for the newly created or
191-
updated resource.
190+
191+
``SCIMResponse`` automatically extracts ``meta.version`` from the serialized body and sets
192+
the ``ETag`` response header, so endpoints do not need to set it manually.
192193

193194
.. tip::
194195

0 commit comments

Comments
 (0)