From 3a3222e1f87688a3ff642700cf48144c53cb21f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Fri, 3 Apr 2026 18:13:52 +0200 Subject: [PATCH] feat: add context type aliases shortcuts --- doc/changelog.rst | 7 + doc/guides/_examples/django_example.py | 19 ++- doc/guides/_examples/fastapi_example.py | 119 +++++++++------- doc/guides/_examples/flask_example.py | 21 ++- doc/guides/fastapi.rst | 35 +++-- doc/tutorial.rst | 81 +++++++---- pyproject.toml | 2 +- scim2_models/__init__.py | 36 +++-- scim2_models/annotated.py | 140 ++++++++++++++++++- scim2_models/messages/response_parameters.py | 2 + tests/test_annotated.py | 125 +++++++++++++++++ uv.lock | 4 +- 12 files changed, 476 insertions(+), 115 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index c28414c..2a04383 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,13 @@ Changelog ========= +[0.6.9] - Unreleased +-------------------- + +Added +^^^^^ +- ``*RequestContext`` and ``*ResponseContext`` generic type aliases that wrap :class:`~scim2_models.SCIMValidator` and :class:`~scim2_models.SCIMSerializer` for each SCIM context (e.g. ``CreationRequestContext[User]``, ``CreationResponseContext[User]``). + [0.6.8] - 2026-04-03 -------------------- diff --git a/doc/guides/_examples/django_example.py b/doc/guides/_examples/django_example.py index 850fbc9..c203eed 100644 --- a/doc/guides/_examples/django_example.py +++ b/doc/guides/_examples/django_example.py @@ -163,6 +163,7 @@ def delete(self, request, app_record): def put(self, request, app_record): if resp := check_etag(app_record, request): return resp + req = ResponseParameters.model_validate(request.GET.dict()) existing_user = to_scim_user(app_record, resource_location(request, app_record)) try: replacement = User.model_validate( @@ -187,13 +188,16 @@ def put(self, request, app_record): ) return scim_response( response_user.model_dump_json( - scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE + scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, ) ) def patch(self, request, app_record): if resp := check_etag(app_record, request): return resp + req = ResponseParameters.model_validate(request.GET.dict()) try: patch = PatchOp[User].model_validate( json.loads(request.body), @@ -212,7 +216,11 @@ def patch(self, request, app_record): return scim_exception_error(error) return scim_response( - scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE) + scim_user.model_dump_json( + scim_ctx=Context.RESOURCE_PATCH_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ) ) # -- single-resource-end -- @@ -247,6 +255,7 @@ def get(self, request): ) def post(self, request): + req = ResponseParameters.model_validate(request.GET.dict()) try: request_user = User.model_validate( json.loads(request.body), @@ -263,7 +272,11 @@ def post(self, request): response_user = to_scim_user(app_record, resource_location(request, app_record)) return scim_response( - response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE), + response_user.model_dump_json( + scim_ctx=Context.RESOURCE_CREATION_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ), HTTPStatus.CREATED, ) diff --git a/doc/guides/_examples/fastapi_example.py b/doc/guides/_examples/fastapi_example.py index 7e683f7..25a8e49 100644 --- a/doc/guides/_examples/fastapi_example.py +++ b/doc/guides/_examples/fastapi_example.py @@ -1,26 +1,30 @@ import json from http import HTTPStatus from typing import Annotated +from typing import Any from fastapi import APIRouter from fastapi import Depends from fastapi import FastAPI from fastapi import HTTPException +from fastapi import Query from fastapi import Request from fastapi import Response from pydantic import ValidationError from scim2_models import Context +from scim2_models import CreationRequestContext from scim2_models import Error from scim2_models import ListResponse from scim2_models import PatchOp +from scim2_models import PatchRequestContext +from scim2_models import QueryResponseContext +from scim2_models import ReplacementRequestContext from scim2_models import ResourceType from scim2_models import ResponseParameters from scim2_models import Schema from scim2_models import SCIMException -from scim2_models import SCIMSerializer from scim2_models import ServiceProviderConfig -from scim2_models import SCIMValidator from scim2_models import SearchRequest from scim2_models import User @@ -46,16 +50,14 @@ class SCIMResponse(Response): media_type = "application/scim+json" - def __init__(self, content=None, **kwargs): - if isinstance(content, (dict, list)): - content = json.dumps(content, ensure_ascii=False) - super().__init__(content=content, **kwargs) - try: - meta = json.loads(content).get("meta", {}) - if version := meta.get("version"): - self.headers["ETag"] = version - except (json.JSONDecodeError, AttributeError, TypeError): - pass + def render(self, content: Any) -> bytes: + self._etag = (content or {}).get("meta", {}).get("version") + return json.dumps(content, ensure_ascii=False).encode("utf-8") + + def __init__(self, content: Any = None, **kwargs: Any) -> None: + super().__init__(content, **kwargs) + if self._etag: + self.headers["ETag"] = self._etag router = APIRouter(prefix="/scim/v2", default_response_class=SCIMResponse) @@ -103,21 +105,21 @@ def resolve_user(user_id: str): async def handle_validation_error(request, error): """Turn Pydantic validation errors into SCIM error responses.""" scim_error = Error.from_validation_error(error.errors()[0]) - return SCIMResponse(scim_error.model_dump_json(), status_code=scim_error.status) + return SCIMResponse(scim_error.model_dump(), status_code=scim_error.status) @app.exception_handler(HTTPException) async def handle_http_exception(request, error): """Turn HTTP exceptions into SCIM error responses.""" scim_error = Error(status=error.status_code, detail=error.detail or "") - return SCIMResponse(scim_error.model_dump_json(), status_code=error.status_code) + return SCIMResponse(scim_error.model_dump(), status_code=error.status_code) @app.exception_handler(SCIMException) async def handle_scim_error(request, error): """Turn SCIM exceptions into SCIM error responses.""" scim_error = error.to_error() - return SCIMResponse(scim_error.model_dump_json(), status_code=scim_error.status) + return SCIMResponse(scim_error.model_dump(), status_code=scim_error.status) # -- error-handlers-end -- # -- refinements-end -- @@ -126,16 +128,19 @@ async def handle_scim_error(request, error): # -- single-resource-start -- # -- get-user-start -- @router.get("/Users/{user_id}") -async def get_user(request: Request, app_record: dict = Depends(resolve_user)): +async def get_user( + request: Request, + req: Annotated[ResponseParameters, Query()], + app_record: dict = Depends(resolve_user), +): """Return one SCIM user.""" - req = ResponseParameters.model_validate(dict(request.query_params)) scim_user = to_scim_user(app_record, resource_location(request, app_record)) etag = make_etag(app_record) if_none_match = request.headers.get("If-None-Match") if if_none_match and etag in [t.strip() for t in if_none_match.split(",")]: return Response(status_code=HTTPStatus.NOT_MODIFIED) return SCIMResponse( - scim_user.model_dump_json( + scim_user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=req.attributes, excluded_attributes=req.excluded_attributes, @@ -148,11 +153,10 @@ async def get_user(request: Request, app_record: dict = Depends(resolve_user)): @router.patch("/Users/{user_id}") async def patch_user( request: Request, - patch: Annotated[ - PatchOp[User], SCIMValidator(Context.RESOURCE_PATCH_REQUEST) - ], + patch: PatchRequestContext[PatchOp[User]], + req: Annotated[ResponseParameters, Query()], app_record: dict = Depends(resolve_user), -) -> Annotated[User, SCIMSerializer(Context.RESOURCE_PATCH_RESPONSE)]: +): """Apply a SCIM PatchOp to an existing user.""" check_etag(app_record, request) scim_user = to_scim_user(app_record, resource_location(request, app_record)) @@ -161,7 +165,14 @@ async def patch_user( updated_record = from_scim_user(scim_user) save_record(updated_record) - return to_scim_user(updated_record, resource_location(request, updated_record)) + response_user = to_scim_user(updated_record, resource_location(request, updated_record)) + return SCIMResponse( + response_user.model_dump( + scim_ctx=Context.RESOURCE_PATCH_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ), + ) # -- patch-user-end -- @@ -169,11 +180,10 @@ async def patch_user( @router.put("/Users/{user_id}") async def replace_user( request: Request, - replacement: Annotated[ - User, SCIMValidator(Context.RESOURCE_REPLACEMENT_REQUEST) - ], + replacement: ReplacementRequestContext[User], + req: Annotated[ResponseParameters, Query()], app_record: dict = Depends(resolve_user), -) -> Annotated[User, SCIMSerializer(Context.RESOURCE_REPLACEMENT_RESPONSE)]: +): """Replace an existing user with a full SCIM resource.""" check_etag(app_record, request) existing_user = to_scim_user(app_record, resource_location(request, app_record)) @@ -183,7 +193,14 @@ async def replace_user( updated_record = from_scim_user(replacement) save_record(updated_record) - return to_scim_user(updated_record, resource_location(request, updated_record)) + response_user = to_scim_user(updated_record, resource_location(request, updated_record)) + return SCIMResponse( + response_user.model_dump( + scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ), + ) # -- put-user-end -- @@ -201,9 +218,10 @@ async def delete_user(request: Request, app_record: dict = Depends(resolve_user) # -- collection-start -- # -- list-users-start -- @router.get("/Users") -async def list_users(request: Request): +async def list_users( + request: Request, req: Annotated[SearchRequest, Query()] +): """Return one page of users as a SCIM ListResponse.""" - req = SearchRequest.model_validate(dict(request.query_params)) total, page = list_records(req.start_index_0, req.stop_index_0) resources = [ to_scim_user(record, resource_location(request, record)) for record in page @@ -215,7 +233,7 @@ async def list_users(request: Request): resources=resources, ) return SCIMResponse( - response.model_dump_json( + response.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=req.attributes, excluded_attributes=req.excluded_attributes, @@ -228,15 +246,22 @@ async def list_users(request: Request): @router.post("/Users", status_code=HTTPStatus.CREATED) async def create_user( request: Request, - request_user: Annotated[ - User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST) - ], -) -> Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]: + request_user: CreationRequestContext[User], + req: Annotated[ResponseParameters, Query()], +): """Validate a SCIM creation payload and store the new user.""" app_record = from_scim_user(request_user) save_record(app_record) - return to_scim_user(app_record, resource_location(request, app_record)) + response_user = to_scim_user(app_record, resource_location(request, app_record)) + return SCIMResponse( + response_user.model_dump( + scim_ctx=Context.RESOURCE_CREATION_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ), + status_code=HTTPStatus.CREATED, + ) # -- create-user-end -- # -- collection-end -- @@ -244,9 +269,8 @@ async def create_user( # -- discovery-start -- # -- schemas-start -- @router.get("/Schemas") -async def list_schemas(request: Request): +async def list_schemas(req: Annotated[SearchRequest, Query()]): """Return one page of SCIM schemas the server exposes.""" - req = SearchRequest.model_validate(dict(request.query_params)) total, page = get_schemas(req.start_index_0, req.stop_index_0) response = ListResponse[Schema]( total_results=total, @@ -255,7 +279,7 @@ async def list_schemas(request: Request): resources=page, ) return SCIMResponse( - response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), + response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), ) @@ -266,18 +290,17 @@ async def get_schema_by_id(schema_id: str): schema = get_schema(schema_id) except KeyError: scim_error = Error(status=404, detail=f"Schema {schema_id!r} not found") - return SCIMResponse(scim_error.model_dump_json(), status_code=HTTPStatus.NOT_FOUND) + return SCIMResponse(scim_error.model_dump(), status_code=HTTPStatus.NOT_FOUND) return SCIMResponse( - schema.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), + schema.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), ) # -- schemas-end -- # -- resource-types-start -- @router.get("/ResourceTypes") -async def list_resource_types(request: Request): +async def list_resource_types(req: Annotated[SearchRequest, Query()]): """Return one page of SCIM resource types the server exposes.""" - req = SearchRequest.model_validate(dict(request.query_params)) total, page = get_resource_types(req.start_index_0, req.stop_index_0) response = ListResponse[ResourceType]( total_results=total, @@ -286,7 +309,7 @@ async def list_resource_types(request: Request): resources=page, ) return SCIMResponse( - response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), + response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), ) @@ -299,17 +322,17 @@ async def get_resource_type_by_id(resource_type_id: str): scim_error = Error( status=404, detail=f"ResourceType {resource_type_id!r} not found" ) - return SCIMResponse(scim_error.model_dump_json(), status_code=HTTPStatus.NOT_FOUND) + return SCIMResponse(scim_error.model_dump(), status_code=HTTPStatus.NOT_FOUND) return SCIMResponse( - rt.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), + rt.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), ) # -- resource-types-end -- # -- service-provider-config-start -- @router.get("/ServiceProviderConfig") -async def get_service_provider_config() -> Annotated[ - ServiceProviderConfig, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE) +async def get_service_provider_config() -> QueryResponseContext[ + ServiceProviderConfig ]: """Return the SCIM service provider configuration.""" return service_provider_config diff --git a/doc/guides/_examples/flask_example.py b/doc/guides/_examples/flask_example.py index 170cd9c..5c7b8b0 100644 --- a/doc/guides/_examples/flask_example.py +++ b/doc/guides/_examples/flask_example.py @@ -141,6 +141,7 @@ def get_user(app_record): def patch_user(app_record): """Apply a SCIM PatchOp to an existing user.""" check_etag(app_record) + req = ResponseParameters.model_validate(request.args.to_dict()) scim_user = to_scim_user(app_record, resource_location(app_record)) patch = PatchOp[User].model_validate( request.get_json(), @@ -152,7 +153,11 @@ def patch_user(app_record): save_record(updated_record) return ( - scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE), + scim_user.model_dump_json( + scim_ctx=Context.RESOURCE_PATCH_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ), HTTPStatus.OK, {"ETag": make_etag(updated_record)}, ) @@ -164,6 +169,7 @@ def patch_user(app_record): def replace_user(app_record): """Replace an existing user with a full SCIM resource.""" check_etag(app_record) + req = ResponseParameters.model_validate(request.args.to_dict()) existing_user = to_scim_user(app_record, resource_location(app_record)) replacement = User.model_validate( request.get_json(), @@ -177,7 +183,11 @@ def replace_user(app_record): response_user = to_scim_user(updated_record, resource_location(updated_record)) return ( - response_user.model_dump_json(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE), + response_user.model_dump_json( + scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ), HTTPStatus.OK, {"ETag": make_etag(updated_record)}, ) @@ -224,6 +234,7 @@ def list_users(): @bp.post("/Users") def create_user(): """Validate a SCIM creation payload and store the new user.""" + req = ResponseParameters.model_validate(request.args.to_dict()) request_user = User.model_validate( request.get_json(), scim_ctx=Context.RESOURCE_CREATION_REQUEST, @@ -233,7 +244,11 @@ def create_user(): response_user = to_scim_user(app_record, resource_location(app_record)) return ( - response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE), + response_user.model_dump_json( + scim_ctx=Context.RESOURCE_CREATION_RESPONSE, + attributes=req.attributes, + excluded_attributes=req.excluded_attributes, + ), HTTPStatus.CREATED, {"ETag": make_etag(app_record)}, ) diff --git a/doc/guides/fastapi.rst b/doc/guides/fastapi.rst index d280433..e4ff4f3 100644 --- a/doc/guides/fastapi.rst +++ b/doc/guides/fastapi.rst @@ -253,36 +253,35 @@ features the server supports (patch, bulk, filtering, etc.). Idiomatic type annotations ========================== -The endpoints above use ``await request.json()`` and explicit -:meth:`~scim2_models.Resource.model_validate` / :meth:`~scim2_models.Resource.model_dump_json` calls. -:mod:`scim2_models` also provides two Pydantic-compatible annotations that let you use -FastAPI's native body parsing and response serialization with the correct SCIM context: - -- :class:`~scim2_models.SCIMValidator` — injects a SCIM :class:`~scim2_models.Context` during - **input validation** (request body parsing). -- :class:`~scim2_models.SCIMSerializer` — injects a SCIM :class:`~scim2_models.Context` during - **output serialization** (response rendering). +The write endpoints above use the context type aliases provided by :mod:`scim2_models`. +``*RequestContext`` aliases wrap :class:`~scim2_models.SCIMValidator` (input validation), +``*ResponseContext`` aliases wrap :class:`~scim2_models.SCIMSerializer` (output serialization): .. code-block:: python - from typing import Annotated - from scim2_models import Context, SCIMSerializer, SCIMValidator, User + from scim2_models import CreationRequestContext, CreationResponseContext, User @router.post("/Users", status_code=201) async def create_user( - user: Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)] - ) -> Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]: + user: CreationRequestContext[User], + ) -> CreationResponseContext[User]: app_record = from_scim_user(user) save_record(app_record) return to_scim_user(app_record, ...) -These annotations are **pure Pydantic** and carry no dependency on FastAPI — they work with any +Available aliases: ``CreationRequestContext`` / ``CreationResponseContext``, +``QueryRequestContext`` / ``QueryResponseContext``, +``ReplacementRequestContext`` / ``ReplacementResponseContext``, +``SearchRequestContext`` / ``SearchResponseContext``, and +``PatchRequestContext`` / ``PatchResponseContext``. + +These aliases are **pure Pydantic** and carry no dependency on FastAPI — they work with any framework that respects :data:`typing.Annotated` metadata. -:class:`~scim2_models.SCIMSerializer` on the return type lets FastAPI handle the response -serialization automatically. -When you need to pass ``attributes`` or ``excluded_attributes`` (for ``GET`` endpoints), -use the explicit ``model_dump_json`` approach shown in the previous sections instead. +``*ResponseContext`` aliases do not support the ``attributes`` / ``excludedAttributes`` +query parameters defined in :rfc:`RFC 7644 §3.9 <7644#section-3.9>`. +When you need to forward those parameters, use the explicit ``model_dump_json`` approach +shown in the previous sections instead. Complete example ================ diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 8469443..7948cbd 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -127,23 +127,26 @@ fields with unexpected values will raise :class:`~pydantic.ValidationError`: Context annotations =================== -:class:`~scim2_models.SCIMValidator` and :class:`~scim2_models.SCIMSerializer` are -`Pydantic Annotated markers `_ -that embed a :class:`~scim2_models.Context` directly in the type hint. -They are useful for web framework integration where the framework handles parsing and -serialization automatically. +Context type aliases +^^^^^^^^^^^^^^^^^^^^ -:class:`~scim2_models.SCIMValidator` injects the context during **validation**: +scim2-models provides generic type aliases that wrap +:class:`~scim2_models.SCIMValidator` and :class:`~scim2_models.SCIMSerializer` for each +SCIM context. ``*RequestContext`` aliases inject the context during **validation**, +``*ResponseContext`` aliases during **serialization**: + +- :class:`~scim2_models.CreationRequestContext` / :class:`~scim2_models.CreationResponseContext` — resource creation (``POST``) +- :class:`~scim2_models.QueryRequestContext` / :class:`~scim2_models.QueryResponseContext` — resource query (``GET``) +- :class:`~scim2_models.ReplacementRequestContext` / :class:`~scim2_models.ReplacementResponseContext` — resource replacement (``PUT``) +- :class:`~scim2_models.SearchRequestContext` / :class:`~scim2_models.SearchResponseContext` — search (``POST /…/.search``) +- :class:`~scim2_models.PatchRequestContext` / :class:`~scim2_models.PatchResponseContext` — patch (``PATCH``) .. code-block:: python - >>> from typing import Annotated >>> from pydantic import TypeAdapter - >>> from scim2_models import User, Context, SCIMValidator + >>> from scim2_models import User, CreationRequestContext, CreationResponseContext - >>> adapter = TypeAdapter( - ... Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)] - ... ) + >>> adapter = TypeAdapter(CreationRequestContext[User]) >>> user = adapter.validate_python({ ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], ... "userName": "bjensen", @@ -152,33 +155,57 @@ serialization automatically. >>> user.id is None True -:class:`~scim2_models.SCIMSerializer` injects the context during **serialization**: - -.. code-block:: python - - >>> from scim2_models import SCIMSerializer - >>> adapter = TypeAdapter( - ... Annotated[User, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)] - ... ) - >>> user = User(user_name="bjensen", password="secret") + >>> adapter = TypeAdapter(CreationResponseContext[User]) >>> user.id = "123" >>> data = adapter.dump_python(user) >>> "password" not in data True -These annotations are **pure Pydantic** and carry no dependency on any web framework. In FastAPI for instance, they can be used directly in endpoint signatures: .. code-block:: python + from scim2_models import CreationRequestContext, CreationResponseContext, User + @router.post("/Users", status_code=201) async def create_user( - user: Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)] - ) -> Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]: + user: CreationRequestContext[User], + ) -> CreationResponseContext[User]: ... See the :doc:`guides/fastapi` guide for a complete example. +.. note:: + + ``*ResponseContext`` aliases do not support the ``attributes`` / + ``excludedAttributes`` parameters defined in + :rfc:`RFC 7644 §3.9 <7644#section-3.9>`. When you need to forward those + parameters, use ``model_dump_json`` explicitly instead. + +Low-level markers +^^^^^^^^^^^^^^^^^ + +For more advanced usage, the underlying markers can be used directly with +:data:`typing.Annotated`: + +- :class:`~scim2_models.SCIMValidator` — injects a context during **validation**. +- :class:`~scim2_models.SCIMSerializer` — injects a context during **serialization**. + +.. code-block:: python + + >>> from typing import Annotated + >>> from pydantic import TypeAdapter + >>> from scim2_models import User, Context, SCIMSerializer + + >>> adapter = TypeAdapter( + ... Annotated[User, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)] + ... ) + >>> user = User(user_name="bjensen", password="secret") + >>> user.id = "123" + >>> data = adapter.dump_python(user) + >>> "password" not in data + True + Attributes inclusions and exclusions ==================================== @@ -330,7 +357,7 @@ scim2-models provides a hierarchy of exceptions corresponding to :rfc:`RFC7644 Each exception can be converted to an :class:`~scim2_models.Error` response object or used in Pydantic validators. Raising exceptions -~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^ Exceptions are named after their ``scimType`` value: @@ -349,7 +376,7 @@ Exceptions are named after their ``scimType`` value: scim2_models.exceptions.PathNotFoundException: The specified path references a non-existent field Converting to Error response -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use :meth:`~scim2_models.SCIMException.to_error` to convert an exception to an :class:`~scim2_models.Error` response: @@ -365,7 +392,7 @@ Use :meth:`~scim2_models.SCIMException.to_error` to convert an exception to an : 'invalidPath' Converting from ValidationError -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use :meth:`Error.from_validation_error ` to convert a single Pydantic error to an :class:`~scim2_models.Error`: @@ -440,7 +467,7 @@ that can take type parameters to represent :rfc:`RFC7643 §7 'referenceTypes'<7 >>> class PetOwner(Resource): ... pet: Reference["Pet"] -:class:`~scim2_models.Reference` has two special type parameters :data:`~scim2_models.External` and :data:`~scim2_models.URI` that matches :rfc:`RFC7643 §7 <7643#section-7>` external and URI reference types. +:class:`~scim2_models.Reference` has two special type parameters :class:`~scim2_models.External` and :class:`~scim2_models.URI` that matches :rfc:`RFC7643 §7 <7643#section-7>` external and URI reference types. Dynamic schemas from models =========================== diff --git a/pyproject.toml b/pyproject.toml index 4af7c6c..80d2d92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ module-root = "" [dependency-groups] dev = [ "django>=5.1", - "fastapi>=0.100.0", + "fastapi>=0.115.0", "flask>=3.0.0", "httpx>=0.24.0", "mypy>=1.13.0", diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 26e1a34..847bc61 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -1,5 +1,15 @@ +from .annotated import CreationRequestContext +from .annotated import CreationResponseContext +from .annotated import PatchRequestContext +from .annotated import PatchResponseContext +from .annotated import QueryRequestContext +from .annotated import QueryResponseContext +from .annotated import ReplacementRequestContext +from .annotated import ReplacementResponseContext from .annotated import SCIMSerializer from .annotated import SCIMValidator +from .annotated import SearchRequestContext +from .annotated import SearchResponseContext from .annotations import CaseExact from .annotations import Mutability from .annotations import Required @@ -73,10 +83,8 @@ __all__ = [ "Address", - "AnyResource", - "SCIMSerializer", - "SCIMValidator", "AnyExtension", + "AnyResource", "Attribute", "AuthenticationScheme", "BaseModel", @@ -88,14 +96,16 @@ "ChangePassword", "ComplexAttribute", "Context", + "CreationRequestContext", + "CreationResponseContext", "ETag", "Email", "EnterpriseUser", "Entitlement", "Error", + "Extension", "External", "ExternalReference", - "Extension", "Filter", "Group", "GroupMember", @@ -115,14 +125,20 @@ "MultiValuedComplexAttribute", "Name", "NoTargetException", - "Path", - "PathNotFoundException", "Patch", "PatchOp", "PatchOperation", + "PatchRequestContext", + "PatchResponseContext", + "Path", + "PathNotFoundException", "PhoneNumber", "Photo", + "QueryRequestContext", + "QueryResponseContext", "Reference", + "ReplacementRequestContext", + "ReplacementResponseContext", "Required", "Resource", "ResourceType", @@ -130,18 +146,22 @@ "Returned", "Role", "SCIMException", + "SCIMSerializer", + "SCIMValidator", "Schema", "SchemaExtension", "SearchRequest", + "SearchRequestContext", + "SearchResponseContext", "SensitiveException", "ServiceProviderConfig", "Sort", "TooManyException", - "Uniqueness", - "UniquenessException", "URI", "URIReference", "URN", + "Uniqueness", + "UniquenessException", "User", "X509Certificate", ] diff --git a/scim2_models/annotated.py b/scim2_models/annotated.py index 126c7ed..8ac84ff 100644 --- a/scim2_models/annotated.py +++ b/scim2_models/annotated.py @@ -4,19 +4,27 @@ :class:`~scim2_models.Context` into Pydantic validation and serialization, making integration with web frameworks like FastAPI idiomatic:: - from typing import Annotated + from scim2_models import CreationRequestContext, CreationResponseContext, User @router.post("/Users", status_code=201) async def create_user( - user: Annotated[User, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)], - ) -> Annotated[User, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]: + user: CreationRequestContext[User], + ) -> CreationResponseContext[User]: ... return response_user """ -import json +import sys +from typing import TYPE_CHECKING +from typing import Annotated from typing import Any +from typing import TypeVar + +if sys.version_info >= (3, 12): + from typing import TypeAliasType +else: # pragma: no cover + from typing_extensions import TypeAliasType from pydantic import GetCoreSchemaHandler from pydantic_core import CoreSchema @@ -24,6 +32,8 @@ async def create_user( from scim2_models.context import Context +T = TypeVar("T") + class SCIMValidator: """Annotated marker that injects a SCIM context during Pydantic validation. @@ -76,7 +86,7 @@ def __get_pydantic_core_schema__( ctx = self.ctx def serialize_with_context(value: Any, _handler: Any) -> Any: - return json.loads(value.model_dump_json(scim_ctx=ctx)) + return value.model_dump(scim_ctx=ctx) return core_schema.no_info_wrap_validator_function( lambda v, h: h(v), @@ -86,3 +96,123 @@ def serialize_with_context(value: Any, _handler: Any) -> Any: schema=schema, ), ) + + +if TYPE_CHECKING: + CreationRequestContext = TypeAliasType( + "CreationRequestContext", + Annotated[T, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)], + type_params=(T,), + ) + CreationResponseContext = TypeAliasType( + "CreationResponseContext", + Annotated[T, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)], + type_params=(T,), + ) + QueryRequestContext = TypeAliasType( + "QueryRequestContext", + Annotated[T, SCIMValidator(Context.RESOURCE_QUERY_REQUEST)], + type_params=(T,), + ) + QueryResponseContext = TypeAliasType( + "QueryResponseContext", + Annotated[T, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)], + type_params=(T,), + ) + ReplacementRequestContext = TypeAliasType( + "ReplacementRequestContext", + Annotated[T, SCIMValidator(Context.RESOURCE_REPLACEMENT_REQUEST)], + type_params=(T,), + ) + ReplacementResponseContext = TypeAliasType( + "ReplacementResponseContext", + Annotated[T, SCIMSerializer(Context.RESOURCE_REPLACEMENT_RESPONSE)], + type_params=(T,), + ) + SearchRequestContext = TypeAliasType( + "SearchRequestContext", + Annotated[T, SCIMValidator(Context.SEARCH_REQUEST)], + type_params=(T,), + ) + SearchResponseContext = TypeAliasType( + "SearchResponseContext", + Annotated[T, SCIMSerializer(Context.SEARCH_RESPONSE)], + type_params=(T,), + ) + PatchRequestContext = TypeAliasType( + "PatchRequestContext", + Annotated[T, SCIMValidator(Context.RESOURCE_PATCH_REQUEST)], + type_params=(T,), + ) + PatchResponseContext = TypeAliasType( + "PatchResponseContext", + Annotated[T, SCIMSerializer(Context.RESOURCE_PATCH_RESPONSE)], + type_params=(T,), + ) +else: + + class _RequestContextAlias: + """Base class for request context type aliases.""" + + _ctx: Context + + def __class_getitem__(cls, item: type) -> Any: + return Annotated[item, SCIMValidator(cls._ctx)] + + class _ResponseContextAlias: + """Base class for response context type aliases.""" + + _ctx: Context + + def __class_getitem__(cls, item: type) -> Any: + return Annotated[item, SCIMSerializer(cls._ctx)] + + class CreationRequestContext(_RequestContextAlias): + """Shortcut for ``Annotated[T, SCIMValidator(Context.RESOURCE_CREATION_REQUEST)]``.""" + + _ctx = Context.RESOURCE_CREATION_REQUEST + + class CreationResponseContext(_ResponseContextAlias): + """Shortcut for ``Annotated[T, SCIMSerializer(Context.RESOURCE_CREATION_RESPONSE)]``.""" + + _ctx = Context.RESOURCE_CREATION_RESPONSE + + class QueryRequestContext(_RequestContextAlias): + """Shortcut for ``Annotated[T, SCIMValidator(Context.RESOURCE_QUERY_REQUEST)]``.""" + + _ctx = Context.RESOURCE_QUERY_REQUEST + + class QueryResponseContext(_ResponseContextAlias): + """Shortcut for ``Annotated[T, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)]``.""" + + _ctx = Context.RESOURCE_QUERY_RESPONSE + + class ReplacementRequestContext(_RequestContextAlias): + """Shortcut for ``Annotated[T, SCIMValidator(Context.RESOURCE_REPLACEMENT_REQUEST)]``.""" + + _ctx = Context.RESOURCE_REPLACEMENT_REQUEST + + class ReplacementResponseContext(_ResponseContextAlias): + """Shortcut for ``Annotated[T, SCIMSerializer(Context.RESOURCE_REPLACEMENT_RESPONSE)]``.""" + + _ctx = Context.RESOURCE_REPLACEMENT_RESPONSE + + class SearchRequestContext(_RequestContextAlias): + """Shortcut for ``Annotated[T, SCIMValidator(Context.SEARCH_REQUEST)]``.""" + + _ctx = Context.SEARCH_REQUEST + + class SearchResponseContext(_ResponseContextAlias): + """Shortcut for ``Annotated[T, SCIMSerializer(Context.SEARCH_RESPONSE)]``.""" + + _ctx = Context.SEARCH_RESPONSE + + class PatchRequestContext(_RequestContextAlias): + """Shortcut for ``Annotated[T, SCIMValidator(Context.RESOURCE_PATCH_REQUEST)]``.""" + + _ctx = Context.RESOURCE_PATCH_REQUEST + + class PatchResponseContext(_ResponseContextAlias): + """Shortcut for ``Annotated[T, SCIMSerializer(Context.RESOURCE_PATCH_RESPONSE)]``.""" + + _ctx = Context.RESOURCE_PATCH_RESPONSE diff --git a/scim2_models/messages/response_parameters.py b/scim2_models/messages/response_parameters.py index 73febb1..b5a53b5 100644 --- a/scim2_models/messages/response_parameters.py +++ b/scim2_models/messages/response_parameters.py @@ -29,6 +29,8 @@ def split_comma_separated(cls, value: Any) -> Any: """ if isinstance(value, str): return [v.strip() for v in value.split(",") if v.strip()] + if isinstance(value, list) and len(value) == 1 and isinstance(value[0], str): + return [v.strip() for v in value[0].split(",") if v.strip()] return value @model_validator(mode="after") diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 14d78ef..2a1580c 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -5,8 +5,18 @@ from pydantic import ValidationError from scim2_models import Context +from scim2_models import CreationRequestContext +from scim2_models import CreationResponseContext +from scim2_models import PatchRequestContext +from scim2_models import PatchResponseContext +from scim2_models import QueryRequestContext +from scim2_models import QueryResponseContext +from scim2_models import ReplacementRequestContext +from scim2_models import ReplacementResponseContext from scim2_models import SCIMSerializer from scim2_models import SCIMValidator +from scim2_models import SearchRequestContext +from scim2_models import SearchResponseContext from scim2_models import User @@ -136,3 +146,118 @@ def test_validator_and_serializer_combined(): data = output_adapter.dump_python(user) assert data["id"] == "server-assigned-id" assert "password" not in data + + +def test_creation_request_context_strips_read_only_fields(): + """CreationRequestContext[User] strips read_only fields during validation.""" + adapter = TypeAdapter(CreationRequestContext[User]) + user = adapter.validate_python( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + "id": "should-be-stripped", + } + ) + assert user.user_name == "bjensen" + assert user.id is None + + +def test_creation_response_context_excludes_write_only_fields(): + """CreationResponseContext[User] excludes write_only fields.""" + adapter = TypeAdapter(CreationResponseContext[User]) + user = User(user_name="bjensen", password="secret") + user.id = "123" + data = adapter.dump_python(user) + assert data["id"] == "123" + assert "password" not in data + + +def test_query_request_context_rejects_write_only_fields(): + """QueryRequestContext[User] rejects write_only fields during validation.""" + adapter = TypeAdapter(QueryRequestContext[User]) + with pytest.raises(ValidationError, match="writeOnly"): + adapter.validate_python( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + "password": "secret", + } + ) + + +def test_query_response_context_excludes_write_only_fields(): + """QueryResponseContext[User] excludes write_only fields.""" + adapter = TypeAdapter(QueryResponseContext[User]) + user = User(user_name="bjensen", password="secret") + user.id = "123" + data = adapter.dump_python(user) + assert "password" not in data + assert data["userName"] == "bjensen" + + +def test_replacement_request_context_accepts_read_write_fields(): + """ReplacementRequestContext[User] accepts read_write fields.""" + adapter = TypeAdapter(ReplacementRequestContext[User]) + user = adapter.validate_python( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + "displayName": "Barbara Jensen", + } + ) + assert user.display_name == "Barbara Jensen" + + +def test_replacement_response_context_excludes_write_only_fields(): + """ReplacementResponseContext[User] excludes write_only fields.""" + adapter = TypeAdapter(ReplacementResponseContext[User]) + user = User(user_name="bjensen", password="secret") + user.id = "123" + data = adapter.dump_python(user) + assert data["id"] == "123" + assert "password" not in data + + +def test_search_request_context_rejects_write_only_fields(): + """SearchRequestContext[User] rejects write_only fields during validation.""" + adapter = TypeAdapter(SearchRequestContext[User]) + with pytest.raises(ValidationError, match="writeOnly"): + adapter.validate_python( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen", + "password": "secret", + } + ) + + +def test_search_response_context_excludes_write_only_fields(): + """SearchResponseContext[User] excludes write_only fields.""" + adapter = TypeAdapter(SearchResponseContext[User]) + user = User(user_name="bjensen", password="secret") + user.id = "123" + data = adapter.dump_python(user) + assert "password" not in data + + +def test_patch_request_context_accepts_partial_payload(): + """PatchRequestContext[User] accepts a partial payload.""" + adapter = TypeAdapter(PatchRequestContext[User]) + user = adapter.validate_python( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "displayName": "Barbara Jensen", + } + ) + assert user.display_name == "Barbara Jensen" + assert user.user_name is None + + +def test_patch_response_context_excludes_write_only_fields(): + """PatchResponseContext[User] excludes write_only fields.""" + adapter = TypeAdapter(PatchResponseContext[User]) + user = User(user_name="bjensen", password="secret") + user.id = "123" + data = adapter.dump_python(user) + assert data["id"] == "123" + assert "password" not in data diff --git a/uv.lock b/uv.lock index b53e4b9..3c4e0fa 100644 --- a/uv.lock +++ b/uv.lock @@ -451,7 +451,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1345,7 +1345,7 @@ requires-dist = [{ name = "pydantic", extras = ["email"], specifier = ">=2.12.0" [package.metadata.requires-dev] dev = [ { name = "django", specifier = ">=5.1" }, - { name = "fastapi", specifier = ">=0.100.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, { name = "flask", specifier = ">=3.0.0" }, { name = "httpx", specifier = ">=0.24.0" }, { name = "mypy", specifier = ">=1.13.0" },