Skip to content

Commit 99c9913

Browse files
committed
doc: ETags improvement in Django integration
1 parent 55e8a9f commit 99c9913

File tree

2 files changed

+113
-99
lines changed

2 files changed

+113
-99
lines changed

doc/guides/_examples/django_example.py

Lines changed: 95 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
from http import HTTPStatus
33

44
from django.http import HttpResponse
5+
from django.http import JsonResponse
56
from django.urls import path
67
from django.urls import register_converter
78
from django.urls import reverse
89
from django.utils.decorators import method_decorator
10+
from django.utils.http import parse_etags
911
from django.views import View
1012
from django.views.decorators.csrf import csrf_exempt
1113
from pydantic import ValidationError
@@ -36,20 +38,60 @@
3638

3739

3840
# -- setup-start --
39-
def scim_response(payload, status=HTTPStatus.OK):
40-
"""Build a Django response with the SCIM media type.
41+
class SCIMJsonResponse(JsonResponse):
42+
"""JSON response with the ``application/scim+json`` media type.
4143
42-
Automatically sets the ``ETag`` header from ``meta.version`` when present.
44+
Keeps a reference to the original data dict in :attr:`scim_data` so that
45+
``dispatch()`` can inspect it without re-parsing the serialised body.
4346
"""
44-
response = HttpResponse(
45-
payload,
46-
status=status,
47-
content_type="application/scim+json",
48-
)
49-
meta = json.loads(payload).get("meta", {})
50-
if version := meta.get("version"):
51-
response["ETag"] = version
52-
return response
47+
48+
def __init__(self, data, **kwargs):
49+
self.scim_data = data
50+
kwargs.setdefault("content_type", "application/scim+json")
51+
super().__init__(data, **kwargs)
52+
53+
54+
@method_decorator(csrf_exempt, name="dispatch")
55+
class SCIMView(View):
56+
"""Base view for SCIM endpoints.
57+
58+
Extracts the ``ETag`` header from ``meta.version``, handles
59+
``If-None-Match`` (304) on GET, and checks ``If-Match`` (412) on
60+
write operations.
61+
"""
62+
63+
# -- etag-start --
64+
def dispatch(self, request, *args, **kwargs):
65+
"""Dispatch with ETag handling."""
66+
if request.method in ("PUT", "PATCH", "DELETE"):
67+
app_record = kwargs.get("app_record")
68+
if app_record is not None:
69+
if_match = request.META.get("HTTP_IF_MATCH")
70+
if if_match and if_match.strip() != "*":
71+
etag = make_etag(app_record)
72+
if etag not in parse_etags(if_match):
73+
scim_error = Error(status=412, detail="ETag mismatch")
74+
return SCIMJsonResponse(
75+
scim_error.model_dump(), status=412
76+
)
77+
78+
response = super().dispatch(request, *args, **kwargs)
79+
80+
data = getattr(response, "scim_data", None)
81+
if data is None:
82+
return response
83+
84+
if meta := data.get("meta"):
85+
if version := meta.get("version"):
86+
response["ETag"] = version
87+
88+
if request.method == "GET" and (etag := response.get("ETag")):
89+
if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
90+
if if_none_match and etag in parse_etags(if_none_match):
91+
return HttpResponse(status=HTTPStatus.NOT_MODIFIED)
92+
93+
return response
94+
# -- etag-end --
5395

5496

5597
def resource_location(request, app_record):
@@ -60,30 +102,6 @@ def resource_location(request, app_record):
60102
# -- setup-end --
61103

62104

63-
# -- etag-start --
64-
def check_etag(record, request):
65-
"""Compare the record's ETag against the ``If-Match`` request header.
66-
67-
:param record: The application record.
68-
:param request: The Django request.
69-
:return: A 412 SCIM error response if the ETag does not match, or :data:`None`.
70-
"""
71-
if_match = request.META.get("HTTP_IF_MATCH")
72-
if not if_match:
73-
return None
74-
if if_match.strip() == "*":
75-
return None
76-
etag = make_etag(record)
77-
tags = [t.strip() for t in if_match.split(",")]
78-
if etag not in tags:
79-
scim_error = Error(status=412, detail="ETag mismatch")
80-
return scim_response(
81-
scim_error.model_dump_json(), HTTPStatus.PRECONDITION_FAILED
82-
)
83-
return None
84-
# -- etag-end --
85-
86-
87105
# -- refinements-start --
88106
# -- converters-start --
89107
class UserConverter:
@@ -107,31 +125,33 @@ def to_url(self, record):
107125
def scim_validation_error(error):
108126
"""Turn Pydantic validation errors into a SCIM error response."""
109127
scim_error = Error.from_validation_error(error.errors()[0])
110-
return scim_response(scim_error.model_dump_json(), scim_error.status)
128+
return SCIMJsonResponse(scim_error.model_dump(), status=scim_error.status)
111129
# -- validation-helper-end --
112130

113131

114132
# -- scim-exception-helper-start --
115133
def scim_exception_error(error):
116134
"""Turn SCIM exceptions into a SCIM error response."""
117135
scim_error = error.to_error()
118-
return scim_response(scim_error.model_dump_json(), scim_error.status)
136+
return SCIMJsonResponse(scim_error.model_dump(), status=scim_error.status)
119137
# -- scim-exception-helper-end --
120138

121139

122140
# -- error-handler-start --
123141
def handler404(request, exception):
124142
"""Turn Django 404 errors into SCIM error responses."""
125143
scim_error = Error(status=404, detail=str(exception))
126-
return scim_response(scim_error.model_dump_json(), HTTPStatus.NOT_FOUND)
144+
return SCIMJsonResponse(
145+
scim_error.model_dump(),
146+
status=HTTPStatus.NOT_FOUND,
147+
)
127148
# -- error-handler-end --
128149
# -- refinements-end --
129150

130151

131152
# -- endpoints-start --
132153
# -- single-resource-start --
133-
@method_decorator(csrf_exempt, name="dispatch")
134-
class UserView(View):
154+
class UserView(SCIMView):
135155
"""Handle GET, PUT, PATCH and DELETE on one SCIM user resource."""
136156

137157
def get(self, request, app_record):
@@ -140,29 +160,20 @@ def get(self, request, app_record):
140160
except ValidationError as error:
141161
return scim_validation_error(error)
142162

143-
etag = make_etag(app_record)
144-
if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
145-
if if_none_match and etag in [t.strip() for t in if_none_match.split(",")]:
146-
return HttpResponse(status=HTTPStatus.NOT_MODIFIED)
147-
148163
scim_user = to_scim_user(app_record, resource_location(request, app_record))
149-
return scim_response(
150-
scim_user.model_dump_json(
164+
return SCIMJsonResponse(
165+
scim_user.model_dump(
151166
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
152167
attributes=req.attributes,
153168
excluded_attributes=req.excluded_attributes,
154169
)
155170
)
156171

157172
def delete(self, request, app_record):
158-
if resp := check_etag(app_record, request):
159-
return resp
160173
delete_record(app_record["id"])
161-
return scim_response("", HTTPStatus.NO_CONTENT)
174+
return HttpResponse(status=HTTPStatus.NO_CONTENT)
162175

163176
def put(self, request, app_record):
164-
if resp := check_etag(app_record, request):
165-
return resp
166177
req = ResponseParameters.model_validate(request.GET.dict())
167178
existing_user = to_scim_user(app_record, resource_location(request, app_record))
168179
try:
@@ -185,17 +196,15 @@ def put(self, request, app_record):
185196
response_user = to_scim_user(
186197
updated_record, resource_location(request, updated_record)
187198
)
188-
return scim_response(
189-
response_user.model_dump_json(
199+
return SCIMJsonResponse(
200+
response_user.model_dump(
190201
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
191202
attributes=req.attributes,
192203
excluded_attributes=req.excluded_attributes,
193204
)
194205
)
195206

196207
def patch(self, request, app_record):
197-
if resp := check_etag(app_record, request):
198-
return resp
199208
req = ResponseParameters.model_validate(request.GET.dict())
200209
try:
201210
patch = PatchOp[User].model_validate(
@@ -214,8 +223,8 @@ def patch(self, request, app_record):
214223
except SCIMException as error:
215224
return scim_exception_error(error)
216225

217-
return scim_response(
218-
scim_user.model_dump_json(
226+
return SCIMJsonResponse(
227+
scim_user.model_dump(
219228
scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
220229
attributes=req.attributes,
221230
excluded_attributes=req.excluded_attributes,
@@ -225,8 +234,7 @@ def patch(self, request, app_record):
225234

226235

227236
# -- collection-start --
228-
@method_decorator(csrf_exempt, name="dispatch")
229-
class UsersView(View):
237+
class UsersView(SCIMView):
230238
"""Handle GET and POST on the SCIM users collection."""
231239

232240
def get(self, request):
@@ -245,8 +253,8 @@ def get(self, request):
245253
items_per_page=len(resources),
246254
resources=resources,
247255
)
248-
return scim_response(
249-
response.model_dump_json(
256+
return SCIMJsonResponse(
257+
response.model_dump(
250258
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
251259
attributes=req.attributes,
252260
excluded_attributes=req.excluded_attributes,
@@ -270,13 +278,13 @@ def post(self, request):
270278
return scim_exception_error(error)
271279

272280
response_user = to_scim_user(app_record, resource_location(request, app_record))
273-
return scim_response(
274-
response_user.model_dump_json(
281+
return SCIMJsonResponse(
282+
response_user.model_dump(
275283
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
276284
attributes=req.attributes,
277285
excluded_attributes=req.excluded_attributes,
278286
),
279-
HTTPStatus.CREATED,
287+
status=HTTPStatus.CREATED,
280288
)
281289

282290

@@ -289,7 +297,7 @@ def post(self, request):
289297

290298
# -- discovery-start --
291299
# -- schemas-start --
292-
class SchemasView(View):
300+
class SchemasView(SCIMView):
293301
"""Handle GET on the SCIM schemas collection."""
294302

295303
def get(self, request):
@@ -305,28 +313,30 @@ def get(self, request):
305313
items_per_page=len(page),
306314
resources=page,
307315
)
308-
return scim_response(
309-
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
316+
return SCIMJsonResponse(
317+
response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
310318
)
311319

312320

313-
class SchemaView(View):
321+
class SchemaView(SCIMView):
314322
"""Handle GET on a single SCIM schema."""
315323

316324
def get(self, request, schema_id):
317325
try:
318326
schema = get_schema(schema_id)
319327
except KeyError:
320328
scim_error = Error(status=404, detail=f"Schema {schema_id!r} not found")
321-
return scim_response(scim_error.model_dump_json(), HTTPStatus.NOT_FOUND)
322-
return scim_response(
323-
schema.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
329+
return SCIMJsonResponse(
330+
scim_error.model_dump(), status=HTTPStatus.NOT_FOUND
331+
)
332+
return SCIMJsonResponse(
333+
schema.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
324334
)
325335
# -- schemas-end --
326336

327337

328338
# -- resource-types-start --
329-
class ResourceTypesView(View):
339+
class ResourceTypesView(SCIMView):
330340
"""Handle GET on the SCIM resource types collection."""
331341

332342
def get(self, request):
@@ -342,12 +352,12 @@ def get(self, request):
342352
items_per_page=len(page),
343353
resources=page,
344354
)
345-
return scim_response(
346-
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
355+
return SCIMJsonResponse(
356+
response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
347357
)
348358

349359

350-
class ResourceTypeView(View):
360+
class ResourceTypeView(SCIMView):
351361
"""Handle GET on a single SCIM resource type."""
352362

353363
def get(self, request, resource_type_id):
@@ -357,20 +367,22 @@ def get(self, request, resource_type_id):
357367
scim_error = Error(
358368
status=404, detail=f"ResourceType {resource_type_id!r} not found"
359369
)
360-
return scim_response(scim_error.model_dump_json(), HTTPStatus.NOT_FOUND)
361-
return scim_response(
362-
rt.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
370+
return SCIMJsonResponse(
371+
scim_error.model_dump(), status=HTTPStatus.NOT_FOUND
372+
)
373+
return SCIMJsonResponse(
374+
rt.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
363375
)
364376
# -- resource-types-end --
365377

366378

367379
# -- service-provider-config-start --
368-
class ServiceProviderConfigView(View):
380+
class ServiceProviderConfigView(SCIMView):
369381
"""Handle GET on the SCIM service provider configuration."""
370382

371383
def get(self, request):
372-
return scim_response(
373-
service_provider_config.model_dump_json(
384+
return SCIMJsonResponse(
385+
service_provider_config.model_dump(
374386
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
375387
)
376388
)

0 commit comments

Comments
 (0)