Skip to content

Commit 55e8a9f

Browse files
committed
doc: ETags improvement in Flask integration
1 parent 76fb72f commit 55e8a9f

File tree

2 files changed

+55
-82
lines changed

2 files changed

+55
-82
lines changed

doc/guides/_examples/flask_example.py

Lines changed: 44 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from http import HTTPStatus
22

33
from flask import Blueprint
4-
from flask import make_response
54
from flask import request
65
from flask import url_for
76
from pydantic import ValidationError
@@ -39,9 +38,14 @@
3938

4039

4140
@bp.after_request
42-
def add_scim_content_type(response):
43-
"""Expose every endpoint with the SCIM media type."""
41+
def scim_after_request(response):
42+
"""Set the SCIM media type, extract ETag, and handle conditional responses."""
4443
response.headers["Content-Type"] = "application/scim+json"
44+
data = response.get_json(silent=True)
45+
if meta := (data or {}).get("meta"):
46+
if version := meta.get("version"):
47+
response.headers["ETag"] = version
48+
response.make_conditional(request)
4549
return response
4650

4751

@@ -52,18 +56,23 @@ def resource_location(app_record):
5256

5357

5458
# -- etag-start --
55-
def check_etag(record):
56-
"""Compare the record's ETag against the ``If-Match`` request header.
59+
@bp.before_request
60+
def check_etag():
61+
"""Verify ``If-Match`` on write operations.
5762
58-
:param record: The application record.
5963
:raises ~werkzeug.exceptions.PreconditionFailed: If the header is present and does not match.
6064
"""
65+
if request.method not in ("PUT", "PATCH", "DELETE"):
66+
return
67+
app_record = request.view_args.get("app_record")
68+
if app_record is None:
69+
return
6170
if_match = request.headers.get("If-Match")
6271
if not if_match:
6372
return
6473
if if_match.strip() == "*":
6574
return
66-
etag = make_etag(record)
75+
etag = make_etag(app_record)
6776
tags = [t.strip() for t in if_match.split(",")]
6877
if etag not in tags:
6978
raise PreconditionFailed("ETag mismatch")
@@ -96,21 +105,21 @@ def _register_converter(state):
96105
def handle_validation_error(error):
97106
"""Turn Pydantic validation errors into SCIM error responses."""
98107
scim_error = Error.from_validation_error(error.errors()[0])
99-
return scim_error.model_dump_json(), scim_error.status
108+
return scim_error.model_dump(), scim_error.status
100109

101110

102111
@bp.errorhandler(HTTPException)
103112
def handle_http_error(error):
104113
"""Turn HTTP errors into SCIM error responses."""
105114
scim_error = Error(status=error.code, detail=str(error.description))
106-
return scim_error.model_dump_json(), error.code
115+
return scim_error.model_dump(), error.code
107116

108117

109118
@bp.errorhandler(SCIMException)
110119
def handle_scim_error(error):
111120
"""Turn SCIM exceptions into SCIM error responses."""
112121
scim_error = error.to_error()
113-
return scim_error.model_dump_json(), scim_error.status
122+
return scim_error.model_dump(), scim_error.status
114123
# -- error-handlers-end --
115124
# -- refinements-end --
116125

@@ -123,24 +132,18 @@ def get_user(app_record):
123132
"""Return one SCIM user."""
124133
req = ResponseParameters.model_validate(request.args.to_dict())
125134
scim_user = to_scim_user(app_record, resource_location(app_record))
126-
resp = make_response(
127-
scim_user.model_dump_json(
128-
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
129-
attributes=req.attributes,
130-
excluded_attributes=req.excluded_attributes,
131-
)
135+
return scim_user.model_dump(
136+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
137+
attributes=req.attributes,
138+
excluded_attributes=req.excluded_attributes,
132139
)
133-
resp.headers["ETag"] = make_etag(app_record)
134-
resp.make_conditional(request)
135-
return resp
136140
# -- get-user-end --
137141

138142

139143
# -- patch-user-start --
140144
@bp.patch("/Users/<user:app_record>")
141145
def patch_user(app_record):
142146
"""Apply a SCIM PatchOp to an existing user."""
143-
check_etag(app_record)
144147
req = ResponseParameters.model_validate(request.args.to_dict())
145148
scim_user = to_scim_user(app_record, resource_location(app_record))
146149
patch = PatchOp[User].model_validate(
@@ -152,14 +155,10 @@ def patch_user(app_record):
152155
updated_record = from_scim_user(scim_user)
153156
save_record(updated_record)
154157

155-
return (
156-
scim_user.model_dump_json(
157-
scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
158-
attributes=req.attributes,
159-
excluded_attributes=req.excluded_attributes,
160-
),
161-
HTTPStatus.OK,
162-
{"ETag": make_etag(updated_record)},
158+
return scim_user.model_dump(
159+
scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
160+
attributes=req.attributes,
161+
excluded_attributes=req.excluded_attributes,
163162
)
164163
# -- patch-user-end --
165164

@@ -168,7 +167,6 @@ def patch_user(app_record):
168167
@bp.put("/Users/<user:app_record>")
169168
def replace_user(app_record):
170169
"""Replace an existing user with a full SCIM resource."""
171-
check_etag(app_record)
172170
req = ResponseParameters.model_validate(request.args.to_dict())
173171
existing_user = to_scim_user(app_record, resource_location(app_record))
174172
replacement = User.model_validate(
@@ -181,14 +179,10 @@ def replace_user(app_record):
181179
save_record(updated_record)
182180

183181
response_user = to_scim_user(updated_record, resource_location(updated_record))
184-
return (
185-
response_user.model_dump_json(
186-
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
187-
attributes=req.attributes,
188-
excluded_attributes=req.excluded_attributes,
189-
),
190-
HTTPStatus.OK,
191-
{"ETag": make_etag(updated_record)},
182+
return response_user.model_dump(
183+
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
184+
attributes=req.attributes,
185+
excluded_attributes=req.excluded_attributes,
192186
)
193187
# -- put-user-end --
194188

@@ -197,7 +191,6 @@ def replace_user(app_record):
197191
@bp.delete("/Users/<user:app_record>")
198192
def delete_user(app_record):
199193
"""Delete an existing user."""
200-
check_etag(app_record)
201194
delete_record(app_record["id"])
202195
return "", HTTPStatus.NO_CONTENT
203196
# -- delete-user-end --
@@ -218,13 +211,10 @@ def list_users():
218211
items_per_page=len(resources),
219212
resources=resources,
220213
)
221-
return (
222-
response.model_dump_json(
223-
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
224-
attributes=req.attributes,
225-
excluded_attributes=req.excluded_attributes,
226-
),
227-
HTTPStatus.OK,
214+
return response.model_dump(
215+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
216+
attributes=req.attributes,
217+
excluded_attributes=req.excluded_attributes,
228218
)
229219
# -- list-users-end --
230220

@@ -243,13 +233,12 @@ def create_user():
243233

244234
response_user = to_scim_user(app_record, resource_location(app_record))
245235
return (
246-
response_user.model_dump_json(
236+
response_user.model_dump(
247237
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
248238
attributes=req.attributes,
249239
excluded_attributes=req.excluded_attributes,
250240
),
251241
HTTPStatus.CREATED,
252-
{"ETag": make_etag(app_record)},
253242
)
254243
# -- create-user-end --
255244
# -- collection-end --
@@ -268,10 +257,7 @@ def list_schemas():
268257
items_per_page=len(page),
269258
resources=page,
270259
)
271-
return (
272-
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
273-
HTTPStatus.OK,
274-
)
260+
return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
275261

276262

277263
@bp.get("/Schemas/<path:schema_id>")
@@ -281,11 +267,8 @@ def get_schema_by_id(schema_id):
281267
schema = get_schema(schema_id)
282268
except KeyError:
283269
scim_error = Error(status=404, detail=f"Schema {schema_id!r} not found")
284-
return scim_error.model_dump_json(), HTTPStatus.NOT_FOUND
285-
return (
286-
schema.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
287-
HTTPStatus.OK,
288-
)
270+
return scim_error.model_dump(), HTTPStatus.NOT_FOUND
271+
return schema.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
289272
# -- schemas-end --
290273

291274

@@ -301,10 +284,7 @@ def list_resource_types():
301284
items_per_page=len(page),
302285
resources=page,
303286
)
304-
return (
305-
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
306-
HTTPStatus.OK,
307-
)
287+
return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
308288

309289

310290
@bp.get("/ResourceTypes/<resource_type_id>")
@@ -316,23 +296,17 @@ def get_resource_type_by_id(resource_type_id):
316296
scim_error = Error(
317297
status=404, detail=f"ResourceType {resource_type_id!r} not found"
318298
)
319-
return scim_error.model_dump_json(), HTTPStatus.NOT_FOUND
320-
return (
321-
rt.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
322-
HTTPStatus.OK,
323-
)
299+
return scim_error.model_dump(), HTTPStatus.NOT_FOUND
300+
return rt.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
324301
# -- resource-types-end --
325302

326303

327304
# -- service-provider-config-start --
328305
@bp.get("/ServiceProviderConfig")
329306
def get_service_provider_config():
330307
"""Return the SCIM service provider configuration."""
331-
return (
332-
service_provider_config.model_dump_json(
333-
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
334-
),
335-
HTTPStatus.OK,
308+
return service_provider_config.model_dump(
309+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
336310
)
337311
# -- service-provider-config-end --
338312
# -- discovery-end --

doc/guides/flask.rst

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,17 @@ Resource versioning (ETags)
163163

164164
SCIM supports resource versioning through HTTP ETags
165165
(:rfc:`RFC 7644 §3.14 <7644#section-3.14>`).
166-
``check_etag`` reads the ``If-Match`` header from the current request, compares it against
167-
the record's ETag and raises :class:`~werkzeug.exceptions.PreconditionFailed` on mismatch.
166+
Both ETag checks are handled centrally rather than in individual endpoints:
167+
168+
- The ``after_request`` hook extracts ``meta.version`` from the response body and sets
169+
the ``ETag`` response header. Werkzeug's
170+
:meth:`~werkzeug.wrappers.Response.make_conditional` handles ``If-None-Match`` to
171+
return a ``304 Not Modified`` when the client already has the current version.
172+
- The ``before_request`` hook reads the ``If-Match`` header on write operations
173+
(``PUT``, ``PATCH``, ``DELETE``) and raises
174+
:class:`~werkzeug.exceptions.PreconditionFailed` on mismatch, since
175+
:meth:`~werkzeug.wrappers.Response.make_conditional` only acts on ``GET``/``HEAD``.
176+
168177
``make_etag`` computes a weak ETag from each record and populates
169178
:attr:`~scim2_models.Meta.version`.
170179

@@ -173,16 +182,6 @@ the record's ETag and raises :class:`~werkzeug.exceptions.PreconditionFailed` on
173182
:start-after: # -- etag-start --
174183
:end-before: # -- etag-end --
175184

176-
On ``GET`` single-resource responses, the ``ETag`` header is set and Werkzeug's
177-
:meth:`~werkzeug.wrappers.Response.make_conditional` handles ``If-None-Match`` to return a
178-
``304 Not Modified`` when the client already has the current version.
179-
180-
On write operations (``PUT``, ``PATCH``, ``DELETE``), the ``If-Match`` header is checked
181-
before processing.
182-
If the client's ETag does not match, a ``412 Precondition Failed`` SCIM error is returned.
183-
``POST`` and ``PUT``/``PATCH`` responses include the ``ETag`` header for the newly created or
184-
updated resource.
185-
186185
.. tip::
187186

188187
If your application uses SQLAlchemy, the built-in

0 commit comments

Comments
 (0)