Skip to content

Commit afdc086

Browse files
committed
feat: add SCIMValidator and SCIMSerializer annotations
Pydantic-compatible Annotated markers that inject a SCIM Context during validation and serialization. Works with any framework that respects Annotated metadata.
1 parent 88991b8 commit afdc086

File tree

6 files changed

+371
-46
lines changed

6 files changed

+371
-46
lines changed

doc/guides/_examples/fastapi_example.py

Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
from http import HTTPStatus
3+
from typing import Annotated
34

45
from fastapi import APIRouter
56
from fastapi import Depends
@@ -17,6 +18,9 @@
1718
from scim2_models import ResponseParameters
1819
from scim2_models import Schema
1920
from scim2_models import SCIMException
21+
from scim2_models import SCIMSerializer
22+
from scim2_models import ServiceProviderConfig
23+
from scim2_models import SCIMValidator
2024
from scim2_models import SearchRequest
2125
from scim2_models import User
2226

@@ -42,11 +46,16 @@ class SCIMResponse(Response):
4246

4347
media_type = "application/scim+json"
4448

45-
def __init__(self, content: str, **kwargs):
49+
def __init__(self, content=None, **kwargs):
50+
if isinstance(content, (dict, list)):
51+
content = json.dumps(content, ensure_ascii=False)
4652
super().__init__(content=content, **kwargs)
47-
meta = json.loads(content).get("meta", {})
48-
if version := meta.get("version"):
49-
self.headers["ETag"] = version
53+
try:
54+
meta = json.loads(content).get("meta", {})
55+
if version := meta.get("version"):
56+
self.headers["ETag"] = version
57+
except (json.JSONDecodeError, AttributeError, TypeError):
58+
pass
5059

5160

5261
router = APIRouter(prefix="/scim/v2", default_response_class=SCIMResponse)
@@ -137,47 +146,44 @@ async def get_user(request: Request, app_record: dict = Depends(resolve_user)):
137146

138147
# -- patch-user-start --
139148
@router.patch("/Users/{user_id}")
140-
async def patch_user(request: Request, app_record: dict = Depends(resolve_user)):
149+
async def patch_user(
150+
request: Request,
151+
patch: Annotated[
152+
PatchOp[User], SCIMValidator(Context.RESOURCE_PATCH_REQUEST)
153+
],
154+
app_record: dict = Depends(resolve_user),
155+
) -> Annotated[User, SCIMSerializer(Context.RESOURCE_PATCH_RESPONSE)]:
141156
"""Apply a SCIM PatchOp to an existing user."""
142157
check_etag(app_record, request)
143158
scim_user = to_scim_user(app_record, resource_location(request, app_record))
144-
patch = PatchOp[User].model_validate(
145-
await request.json(),
146-
scim_ctx=Context.RESOURCE_PATCH_REQUEST,
147-
)
148159
patch.patch(scim_user)
149160

150161
updated_record = from_scim_user(scim_user)
151162
save_record(updated_record)
152163

153-
return SCIMResponse(
154-
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE),
155-
)
164+
return to_scim_user(updated_record, resource_location(request, updated_record))
156165
# -- patch-user-end --
157166

158167

159168
# -- put-user-start --
160169
@router.put("/Users/{user_id}")
161-
async def replace_user(request: Request, app_record: dict = Depends(resolve_user)):
170+
async def replace_user(
171+
request: Request,
172+
replacement: Annotated[
173+
User, SCIMValidator(Context.RESOURCE_REPLACEMENT_REQUEST)
174+
],
175+
app_record: dict = Depends(resolve_user),
176+
) -> Annotated[User, SCIMSerializer(Context.RESOURCE_REPLACEMENT_RESPONSE)]:
162177
"""Replace an existing user with a full SCIM resource."""
163178
check_etag(app_record, request)
164179
existing_user = to_scim_user(app_record, resource_location(request, app_record))
165-
replacement = User.model_validate(
166-
await request.json(),
167-
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
168-
)
169180
replacement.replace(existing_user)
170181

171182
replacement.id = existing_user.id
172183
updated_record = from_scim_user(replacement)
173184
save_record(updated_record)
174185

175-
response_user = to_scim_user(
176-
updated_record, resource_location(request, updated_record)
177-
)
178-
return SCIMResponse(
179-
response_user.model_dump_json(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE),
180-
)
186+
return to_scim_user(updated_record, resource_location(request, updated_record))
181187
# -- put-user-end --
182188

183189

@@ -219,21 +225,18 @@ async def list_users(request: Request):
219225

220226

221227
# -- create-user-start --
222-
@router.post("/Users")
223-
async def create_user(request: Request):
228+
@router.post("/Users", status_code=HTTPStatus.CREATED)
229+
async def create_user(
230+
request: Request,
231+
request_user: Annotated[
232+
User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)
233+
],
234+
) -> Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]:
224235
"""Validate a SCIM creation payload and store the new user."""
225-
request_user = User.model_validate(
226-
await request.json(),
227-
scim_ctx=Context.RESOURCE_CREATION_REQUEST,
228-
)
229236
app_record = from_scim_user(request_user)
230237
save_record(app_record)
231238

232-
response_user = to_scim_user(app_record, resource_location(request, app_record))
233-
return SCIMResponse(
234-
response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE),
235-
status_code=HTTPStatus.CREATED,
236-
)
239+
return to_scim_user(app_record, resource_location(request, app_record))
237240
# -- create-user-end --
238241
# -- collection-end --
239242

@@ -305,13 +308,11 @@ async def get_resource_type_by_id(resource_type_id: str):
305308

306309
# -- service-provider-config-start --
307310
@router.get("/ServiceProviderConfig")
308-
async def get_service_provider_config():
311+
async def get_service_provider_config() -> Annotated[
312+
ServiceProviderConfig, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)
313+
]:
309314
"""Return the SCIM service provider configuration."""
310-
return SCIMResponse(
311-
service_provider_config.model_dump_json(
312-
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
313-
),
314-
)
315+
return service_provider_config
315316
# -- service-provider-config-end --
316317
# -- discovery-end --
317318

doc/guides/fastapi.rst

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ Endpoints
8383
The routes below serve ``/Users``, but the same structure applies to any resource type:
8484
replace the mapping helpers, the model class, and the URL prefix to expose ``/Groups`` or
8585
any other collection.
86-
All route functions are ``async`` because the request body is read with ``await request.json()``.
86+
Write endpoints use :class:`~scim2_models.SCIMValidator` to let FastAPI parse and validate
87+
the request body with the correct SCIM :class:`~scim2_models.Context` automatically.
88+
Read endpoints still build responses explicitly because they need to forward
89+
``attributes`` / ``excludedAttributes`` query parameters.
8790

8891
GET /Users/<id>
8992
^^^^^^^^^^^^^^^
@@ -113,8 +116,9 @@ No SCIM serialization is needed.
113116
PATCH /Users/<id>
114117
^^^^^^^^^^^^^^^^^
115118

116-
Validate the patch payload with :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`,
117-
apply it to a SCIM conversion of the native record with :meth:`~scim2_models.PatchOp.patch`,
119+
The patch payload is validated through :class:`~scim2_models.SCIMValidator` with
120+
:attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`.
121+
Apply it to a SCIM conversion of the native record with :meth:`~scim2_models.PatchOp.patch`,
118122
convert back to native and persist, then serialize the result with
119123
:attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE`.
120124
:class:`~scim2_models.PatchOp` is generic and works with any resource type.
@@ -127,7 +131,7 @@ convert back to native and persist, then serialize the result with
127131
PUT /Users/<id>
128132
^^^^^^^^^^^^^^^
129133

130-
Validate the full replacement payload with
134+
The full replacement payload is validated through :class:`~scim2_models.SCIMValidator` with
131135
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then call
132136
:meth:`~scim2_models.Resource.replace` to verify that immutable attributes
133137
have not been modified.
@@ -156,8 +160,9 @@ Pass ``req.attributes`` and ``req.excluded_attributes`` to
156160
POST /Users
157161
^^^^^^^^^^^
158162

159-
Validate the creation payload with :attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`,
160-
convert to native and persist, then serialize the created resource with
163+
The creation payload is validated through :class:`~scim2_models.SCIMValidator` with
164+
:attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`.
165+
Convert to native and persist, then serialize the created resource with
161166
:attr:`~scim2_models.Context.RESOURCE_CREATION_RESPONSE`.
162167

163168
.. literalinclude:: _examples/fastapi_example.py
@@ -245,6 +250,40 @@ features the server supports (patch, bulk, filtering, etc.).
245250
:start-after: # -- service-provider-config-start --
246251
:end-before: # -- service-provider-config-end --
247252

253+
Idiomatic type annotations
254+
==========================
255+
256+
The endpoints above use ``await request.json()`` and explicit
257+
:meth:`~scim2_models.Resource.model_validate` / :meth:`~scim2_models.Resource.model_dump_json` calls.
258+
:mod:`scim2_models` also provides two Pydantic-compatible annotations that let you use
259+
FastAPI's native body parsing and response serialization with the correct SCIM context:
260+
261+
- :class:`~scim2_models.SCIMValidator` — injects a SCIM :class:`~scim2_models.Context` during
262+
**input validation** (request body parsing).
263+
- :class:`~scim2_models.SCIMSerializer` — injects a SCIM :class:`~scim2_models.Context` during
264+
**output serialization** (response rendering).
265+
266+
.. code-block:: python
267+
268+
from typing import Annotated
269+
from scim2_models import Context, SCIMSerializer, SCIMValidator, User
270+
271+
@router.post("/Users", status_code=201)
272+
async def create_user(
273+
user: Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)]
274+
) -> Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]:
275+
app_record = from_scim_user(user)
276+
save_record(app_record)
277+
return to_scim_user(app_record, ...)
278+
279+
These annotations are **pure Pydantic** and carry no dependency on FastAPI — they work with any
280+
framework that respects :data:`typing.Annotated` metadata.
281+
282+
:class:`~scim2_models.SCIMSerializer` on the return type lets FastAPI handle the response
283+
serialization automatically.
284+
When you need to pass ``attributes`` or ``excluded_attributes`` (for ``GET`` endpoints),
285+
use the explicit ``model_dump_json`` approach shown in the previous sections instead.
286+
248287
Complete example
249288
================
250289

doc/tutorial.rst

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,61 @@ fields with unexpected values will raise :class:`~pydantic.ValidationError`:
124124
... except pydantic.ValidationError:
125125
... obj = Error(...)
126126
127+
Context annotations
128+
===================
129+
130+
:class:`~scim2_models.SCIMValidator` and :class:`~scim2_models.SCIMSerializer` are
131+
`Pydantic Annotated markers <https://docs.pydantic.dev/latest/concepts/types/#custom-types>`_
132+
that embed a :class:`~scim2_models.Context` directly in the type hint.
133+
They are useful for web framework integration where the framework handles parsing and
134+
serialization automatically.
135+
136+
:class:`~scim2_models.SCIMValidator` injects the context during **validation**:
137+
138+
.. code-block:: python
139+
140+
>>> from typing import Annotated
141+
>>> from pydantic import TypeAdapter
142+
>>> from scim2_models import User, Context, SCIMValidator
143+
144+
>>> adapter = TypeAdapter(
145+
... Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)]
146+
... )
147+
>>> user = adapter.validate_python({
148+
... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
149+
... "userName": "bjensen",
150+
... "id": "should-be-stripped",
151+
... })
152+
>>> user.id is None
153+
True
154+
155+
:class:`~scim2_models.SCIMSerializer` injects the context during **serialization**:
156+
157+
.. code-block:: python
158+
159+
>>> from scim2_models import SCIMSerializer
160+
>>> adapter = TypeAdapter(
161+
... Annotated[User, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)]
162+
... )
163+
>>> user = User(user_name="bjensen", password="secret")
164+
>>> user.id = "123"
165+
>>> data = adapter.dump_python(user)
166+
>>> "password" not in data
167+
True
168+
169+
These annotations are **pure Pydantic** and carry no dependency on any web framework.
170+
In FastAPI for instance, they can be used directly in endpoint signatures:
171+
172+
.. code-block:: python
173+
174+
@router.post("/Users", status_code=201)
175+
async def create_user(
176+
user: Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)]
177+
) -> Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]:
178+
...
179+
180+
See the :doc:`guides/fastapi` guide for a complete example.
181+
127182
Attributes inclusions and exclusions
128183
====================================
129184

scim2_models/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from .annotated import SCIMSerializer
2+
from .annotated import SCIMValidator
13
from .annotations import CaseExact
24
from .annotations import Mutability
35
from .annotations import Required
@@ -72,6 +74,8 @@
7274
__all__ = [
7375
"Address",
7476
"AnyResource",
77+
"SCIMSerializer",
78+
"SCIMValidator",
7579
"AnyExtension",
7680
"Attribute",
7781
"AuthenticationScheme",

0 commit comments

Comments
 (0)