Skip to content

Commit dfd4492

Browse files
committed
doc: improve etags documentation
1 parent db75212 commit dfd4492

File tree

8 files changed

+173
-119
lines changed

8 files changed

+173
-119
lines changed

doc/guides/_examples/django_example.py

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,11 @@
1616
from scim2_models import PatchOp
1717
from scim2_models import ResourceType
1818
from scim2_models import ResponseParameters
19-
from scim2_models import SCIMException
2019
from scim2_models import Schema
20+
from scim2_models import SCIMException
2121
from scim2_models import SearchRequest
2222
from scim2_models import User
2323

24-
from .integrations import check_etag
2524
from .integrations import delete_record
2625
from .integrations import from_scim_user
2726
from .integrations import get_record
@@ -31,11 +30,11 @@
3130
from .integrations import get_schemas
3231
from .integrations import list_records
3332
from .integrations import make_etag
34-
from .integrations import PreconditionFailed
3533
from .integrations import save_record
3634
from .integrations import service_provider_config
3735
from .integrations import to_scim_user
3836

37+
3938
# -- setup-start --
4039
def scim_response(payload, status=HTTPStatus.OK):
4140
"""Build a Django response with the SCIM media type."""
@@ -54,6 +53,30 @@ def resource_location(request, app_record):
5453
# -- setup-end --
5554

5655

56+
# -- etag-start --
57+
def check_etag(record, request):
58+
"""Compare the record's ETag against the ``If-Match`` request header.
59+
60+
:param record: The application record.
61+
:param request: The Django request.
62+
:return: A 412 SCIM error response if the ETag does not match, or :data:`None`.
63+
"""
64+
if_match = request.META.get("HTTP_IF_MATCH")
65+
if not if_match:
66+
return None
67+
if if_match.strip() == "*":
68+
return None
69+
etag = make_etag(record)
70+
tags = [t.strip() for t in if_match.split(",")]
71+
if etag not in tags:
72+
scim_error = Error(status=412, detail="ETag mismatch")
73+
return scim_response(
74+
scim_error.model_dump_json(), HTTPStatus.PRECONDITION_FAILED
75+
)
76+
return None
77+
# -- etag-end --
78+
79+
5780
# -- refinements-start --
5881
# -- converters-start --
5982
class UserConverter:
@@ -86,22 +109,18 @@ def scim_exception_error(error):
86109
"""Turn SCIM exceptions into a SCIM error response."""
87110
scim_error = error.to_error()
88111
return scim_response(scim_error.model_dump_json(), scim_error.status)
89-
# -- scim-exception-helper-end --
90112

91113

92-
# -- precondition-helper-start --
93-
def scim_precondition_error():
94-
"""Turn ETag mismatches into a SCIM 412 response."""
95-
scim_error = Error(status=412, detail="ETag mismatch")
96-
return scim_response(scim_error.model_dump_json(), HTTPStatus.PRECONDITION_FAILED)
97-
# -- precondition-helper-end --
114+
# -- scim-exception-helper-end --
98115

99116

100117
# -- error-handler-start --
101118
def handler404(request, exception):
102119
"""Turn Django 404 errors into SCIM error responses."""
103120
scim_error = Error(status=404, detail=str(exception))
104121
return scim_response(scim_error.model_dump_json(), HTTPStatus.NOT_FOUND)
122+
123+
105124
# -- error-handler-end --
106125
# -- refinements-end --
107126

@@ -135,18 +154,14 @@ def get(self, request, app_record):
135154
return resp
136155

137156
def delete(self, request, app_record):
138-
try:
139-
check_etag(app_record, request.META.get("HTTP_IF_MATCH"))
140-
except PreconditionFailed:
141-
return scim_precondition_error()
157+
if resp := check_etag(app_record, request):
158+
return resp
142159
delete_record(app_record["id"])
143160
return scim_response("", HTTPStatus.NO_CONTENT)
144161

145162
def put(self, request, app_record):
146-
try:
147-
check_etag(app_record, request.META.get("HTTP_IF_MATCH"))
148-
except PreconditionFailed:
149-
return scim_precondition_error()
163+
if resp := check_etag(app_record, request):
164+
return resp
150165
existing_user = to_scim_user(app_record, resource_location(request, app_record))
151166
try:
152167
replacement = User.model_validate(
@@ -166,7 +181,9 @@ def put(self, request, app_record):
166181
except SCIMException as error:
167182
return scim_exception_error(error)
168183

169-
response_user = to_scim_user(updated_record, resource_location(request, updated_record))
184+
response_user = to_scim_user(
185+
updated_record, resource_location(request, updated_record)
186+
)
170187
resp = scim_response(
171188
response_user.model_dump_json(
172189
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
@@ -176,10 +193,8 @@ def put(self, request, app_record):
176193
return resp
177194

178195
def patch(self, request, app_record):
179-
try:
180-
check_etag(app_record, request.META.get("HTTP_IF_MATCH"))
181-
except PreconditionFailed:
182-
return scim_precondition_error()
196+
if resp := check_etag(app_record, request):
197+
return resp
183198
try:
184199
patch = PatchOp[User].model_validate(
185200
json.loads(request.body),
@@ -202,6 +217,8 @@ def patch(self, request, app_record):
202217
)
203218
resp["ETag"] = make_etag(updated_record)
204219
return resp
220+
221+
205222
# -- single-resource-end --
206223

207224

@@ -217,7 +234,9 @@ def get(self, request):
217234
return scim_validation_error(error)
218235

219236
total, page = list_records(req.start_index_0, req.stop_index_0)
220-
resources = [to_scim_user(record, resource_location(request, record)) for record in page]
237+
resources = [
238+
to_scim_user(record, resource_location(request, record)) for record in page
239+
]
221240
response = ListResponse[User](
222241
total_results=total,
223242
start_index=req.start_index or 1,
@@ -298,6 +317,8 @@ def get(self, request, schema_id):
298317
return scim_response(
299318
schema.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
300319
)
320+
321+
301322
# -- schemas-end --
302323

303324

@@ -337,6 +358,8 @@ def get(self, request, resource_type_id):
337358
return scim_response(
338359
rt.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
339360
)
361+
362+
340363
# -- resource-types-end --
341364

342365

@@ -350,6 +373,8 @@ def get(self, request):
350373
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
351374
)
352375
)
376+
377+
353378
# -- service-provider-config-end --
354379

355380

doc/guides/_examples/fastapi_example.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@
1414
from scim2_models import PatchOp
1515
from scim2_models import ResourceType
1616
from scim2_models import ResponseParameters
17-
from scim2_models import SCIMException
1817
from scim2_models import Schema
18+
from scim2_models import SCIMException
1919
from scim2_models import SearchRequest
2020
from scim2_models import User
2121

22-
from .integrations import PreconditionFailed
23-
from .integrations import check_etag
2422
from .integrations import delete_record
2523
from .integrations import from_scim_user
2624
from .integrations import get_record
@@ -53,6 +51,25 @@ def resource_location(request, app_record):
5351
# -- setup-end --
5452

5553

54+
# -- etag-start --
55+
def check_etag(record, if_match):
56+
"""Compare the record's ETag against an ``If-Match`` header value.
57+
58+
:param record: The application record.
59+
:param if_match: Raw ``If-Match`` header value, or :data:`None`.
60+
:raises ~fastapi.HTTPException: If the header is present and does not match.
61+
"""
62+
if not if_match:
63+
return
64+
if if_match.strip() == "*":
65+
return
66+
etag = make_etag(record)
67+
tags = [t.strip() for t in if_match.split(",")]
68+
if etag not in tags:
69+
raise HTTPException(status_code=412, detail="ETag mismatch")
70+
# -- etag-end --
71+
72+
5673
# -- refinements-start --
5774
# -- dependency-start --
5875
def resolve_user(user_id: str):
@@ -61,6 +78,8 @@ def resolve_user(user_id: str):
6178
return get_record(user_id)
6279
except KeyError:
6380
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
81+
82+
6483
# -- dependency-end --
6584

6685

@@ -86,13 +105,6 @@ async def handle_scim_error(request, error):
86105
return Response(scim_error.model_dump_json(), status_code=scim_error.status)
87106

88107

89-
@app.exception_handler(PreconditionFailed)
90-
async def handle_precondition_failed(request, error):
91-
"""Turn ETag mismatches into SCIM 412 responses."""
92-
scim_error = Error(status=412, detail="ETag mismatch")
93-
return Response(
94-
scim_error.model_dump_json(), status_code=HTTPStatus.PRECONDITION_FAILED
95-
)
96108
# -- error-handlers-end --
97109
# -- refinements-end --
98110

@@ -117,6 +129,8 @@ async def get_user(request: Request, app_record: dict = Depends(resolve_user)):
117129
),
118130
headers={"ETag": etag},
119131
)
132+
133+
120134
# -- get-user-end --
121135

122136

@@ -139,6 +153,8 @@ async def patch_user(request: Request, app_record: dict = Depends(resolve_user))
139153
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE),
140154
headers={"ETag": make_etag(updated_record)},
141155
)
156+
157+
142158
# -- patch-user-end --
143159

144160

@@ -162,11 +178,11 @@ async def replace_user(request: Request, app_record: dict = Depends(resolve_user
162178
updated_record, resource_location(request, updated_record)
163179
)
164180
return Response(
165-
response_user.model_dump_json(
166-
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
167-
),
181+
response_user.model_dump_json(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE),
168182
headers={"ETag": make_etag(updated_record)},
169183
)
184+
185+
170186
# -- put-user-end --
171187

172188

@@ -177,6 +193,8 @@ async def delete_user(request: Request, app_record: dict = Depends(resolve_user)
177193
check_etag(app_record, request.headers.get("If-Match"))
178194
delete_record(app_record["id"])
179195
return Response(status_code=HTTPStatus.NO_CONTENT)
196+
197+
180198
# -- delete-user-end --
181199
# -- single-resource-end --
182200

@@ -204,6 +222,8 @@ async def list_users(request: Request):
204222
excluded_attributes=req.excluded_attributes,
205223
),
206224
)
225+
226+
207227
# -- list-users-end --
208228

209229

@@ -224,6 +244,8 @@ async def create_user(request: Request):
224244
status_code=HTTPStatus.CREATED,
225245
headers={"ETag": make_etag(app_record)},
226246
)
247+
248+
227249
# -- create-user-end --
228250
# -- collection-end --
229251

@@ -257,6 +279,8 @@ async def get_schema_by_id(schema_id: str):
257279
return Response(
258280
schema.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
259281
)
282+
283+
260284
# -- schemas-end --
261285

262286

@@ -290,6 +314,8 @@ async def get_resource_type_by_id(resource_type_id: str):
290314
return Response(
291315
rt.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
292316
)
317+
318+
293319
# -- resource-types-end --
294320

295321

@@ -302,6 +328,8 @@ async def get_service_provider_config():
302328
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
303329
),
304330
)
331+
332+
305333
# -- service-provider-config-end --
306334
# -- discovery-end --
307335

0 commit comments

Comments
 (0)