22from http import HTTPStatus
33
44from django .http import HttpResponse
5+ from django .http import JsonResponse
56from django .urls import path
67from django .urls import register_converter
78from django .urls import reverse
89from django .utils .decorators import method_decorator
10+ from django .utils .http import parse_etags
911from django .views import View
1012from django .views .decorators .csrf import csrf_exempt
1113from pydantic import ValidationError
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
5597def 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 --
89107class UserConverter :
@@ -107,31 +125,33 @@ def to_url(self, record):
107125def 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 --
115133def 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 --
123141def 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