diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index facd74a..ad20888 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,9 @@ repos: hooks: - id: ruff-check args: [--fix, --exit-non-zero-on-fix] + exclude: ^doc/guides/_examples/ - id: ruff-format + exclude: ^doc/guides/_examples/ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: @@ -19,7 +21,7 @@ repos: rev: v1.19.1 hooks: - id: mypy - exclude: ^(tests/|conftest.py) + exclude: ^(tests/|conftest\.py|doc/) additional_dependencies: - pydantic[email]>=2.7.0 - repo: https://github.com/codespell-project/codespell diff --git a/doc/conf.py b/doc/conf.py index 6b29d75..a45a716 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -43,6 +43,7 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "pydantic": ("https://docs.pydantic.dev/latest/", None), + "flask": ("https://flask.palletsprojects.com/en/stable/", None), } # -- Options for HTML output ---------------------------------------------- diff --git a/doc/guides/__init__.py b/doc/guides/__init__.py new file mode 100644 index 0000000..660c84d --- /dev/null +++ b/doc/guides/__init__.py @@ -0,0 +1 @@ +"""Documentation guides package.""" diff --git a/doc/guides/_examples/__init__.py b/doc/guides/_examples/__init__.py new file mode 100644 index 0000000..bfd50c7 --- /dev/null +++ b/doc/guides/_examples/__init__.py @@ -0,0 +1 @@ +"""Runnable examples used by the documentation.""" diff --git a/doc/guides/_examples/django_example.py b/doc/guides/_examples/django_example.py new file mode 100644 index 0000000..cbe8354 --- /dev/null +++ b/doc/guides/_examples/django_example.py @@ -0,0 +1,172 @@ +import json +from http import HTTPStatus + +from django.http import HttpResponse +from django.urls import path +from django.urls import register_converter +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from pydantic import ValidationError + +from scim2_models import Context +from scim2_models import Error +from scim2_models import ListResponse +from scim2_models import PatchOp +from scim2_models import SearchRequest +from scim2_models import UniquenessException +from scim2_models import User + +from .integrations import delete_record +from .integrations import from_scim_user +from .integrations import get_record +from .integrations import list_records +from .integrations import save_record +from .integrations import to_scim_user + +# -- setup-start -- +def scim_response(payload, status=HTTPStatus.OK): + """Build a Django response with the SCIM media type.""" + return HttpResponse( + payload, + status=status, + content_type="application/scim+json", + ) +# -- setup-end -- + + +# -- refinements-start -- +# -- converters-start -- +class UserConverter: + regex = "[^/]+" + + def to_python(self, id): + try: + return get_record(id) + except KeyError: + raise ValueError + + def to_url(self, record): + return record["id"] + + +register_converter(UserConverter, "user") +# -- converters-end -- + + +# -- validation-helper-start -- +def scim_validation_error(error): + """Turn Pydantic validation errors into a SCIM error response.""" + scim_error = Error.from_validation_error(error.errors()[0]) + return scim_response(scim_error.model_dump_json(), scim_error.status) +# -- validation-helper-end -- + + +# -- uniqueness-helper-start -- +def scim_uniqueness_error(error): + """Turn uniqueness errors into a SCIM 409 response.""" + scim_error = UniquenessException(detail=str(error)).to_error() + return scim_response(scim_error.model_dump_json(), HTTPStatus.CONFLICT) +# -- uniqueness-helper-end -- + + +# -- error-handler-start -- +def handler404(request, exception): + """Turn Django 404 errors into SCIM error responses.""" + scim_error = Error(status=404, detail=str(exception)) + return scim_response(scim_error.model_dump_json(), HTTPStatus.NOT_FOUND) +# -- error-handler-end -- +# -- refinements-end -- + + +# -- endpoints-start -- +# -- single-resource-start -- +@method_decorator(csrf_exempt, name="dispatch") +class UserView(View): + """Handle GET, PATCH and DELETE on one SCIM user resource.""" + + def get(self, request, app_record): + scim_user = to_scim_user(app_record) + return scim_response( + scim_user.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + ) + + def delete(self, request, app_record): + delete_record(app_record["id"]) + return scim_response("", HTTPStatus.NO_CONTENT) + + def patch(self, request, app_record): + try: + patch = PatchOp[User].model_validate( + json.loads(request.body), + scim_ctx=Context.RESOURCE_PATCH_REQUEST, + ) + except ValidationError as error: + return scim_validation_error(error) + + scim_user = to_scim_user(app_record) + patch.patch(scim_user) + + updated_record = from_scim_user(scim_user) + try: + save_record(updated_record) + except ValueError as error: + return scim_uniqueness_error(error) + + return scim_response( + scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE) + ) +# -- single-resource-end -- + + +# -- collection-start -- +@method_decorator(csrf_exempt, name="dispatch") +class UsersView(View): + """Handle GET and POST on the SCIM users collection.""" + + def get(self, request): + try: + req = SearchRequest.model_validate(request.GET) + except ValidationError as error: + return scim_validation_error(error) + all_records = list_records() + page = all_records[req.start_index_0 : req.stop_index_0] + resources = [to_scim_user(record) for record in page] + response = ListResponse[User]( + total_results=len(all_records), + start_index=req.start_index or 1, + items_per_page=len(resources), + resources=resources, + ) + return scim_response( + response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + ) + + def post(self, request): + try: + request_user = User.model_validate( + json.loads(request.body), + scim_ctx=Context.RESOURCE_CREATION_REQUEST, + ) + except ValidationError as error: + return scim_validation_error(error) + + app_record = from_scim_user(request_user) + try: + save_record(app_record) + except ValueError as error: + return scim_uniqueness_error(error) + + response_user = to_scim_user(app_record) + return scim_response( + response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE), + HTTPStatus.CREATED, + ) + + +urlpatterns = [ + path("scim/v2/Users", UsersView.as_view(), name="scim_users"), + path("scim/v2/Users/", UserView.as_view(), name="scim_user"), +] +# -- collection-end -- +# -- endpoints-end -- diff --git a/doc/guides/_examples/flask_example.py b/doc/guides/_examples/flask_example.py new file mode 100644 index 0000000..4ee6c24 --- /dev/null +++ b/doc/guides/_examples/flask_example.py @@ -0,0 +1,166 @@ +from http import HTTPStatus + +from flask import Blueprint +from flask import request +from pydantic import ValidationError +from werkzeug.routing import BaseConverter +from werkzeug.routing import ValidationError as RoutingValidationError + +from scim2_models import Context +from scim2_models import Error +from scim2_models import ListResponse +from scim2_models import PatchOp +from scim2_models import SearchRequest +from scim2_models import UniquenessException +from scim2_models import User + +from .integrations import delete_record +from .integrations import from_scim_user +from .integrations import get_record +from .integrations import list_records +from .integrations import save_record +from .integrations import to_scim_user + +# -- setup-start -- +bp = Blueprint("scim", __name__, url_prefix="/scim/v2") + + +@bp.after_request +def add_scim_content_type(response): + """Expose every endpoint with the SCIM media type.""" + response.headers["Content-Type"] = "application/scim+json" + return response +# -- setup-end -- + + +# -- refinements-start -- +# -- converters-start -- +class UserConverter(BaseConverter): + """Resolve a user identifier to an application record.""" + + def to_python(self, id): + try: + return get_record(id) + except KeyError: + raise RoutingValidationError() + + def to_url(self, record): + return record["id"] + + +@bp.record_once +def _register_converter(state): + state.app.url_map.converters["user"] = UserConverter +# -- converters-end -- + + +# -- error-handlers-start -- +@bp.errorhandler(ValidationError) +def handle_validation_error(error): + """Turn Pydantic validation errors into SCIM error responses.""" + scim_error = Error.from_validation_error(error.errors()[0]) + return scim_error.model_dump_json(), scim_error.status + + +@bp.errorhandler(404) +def handle_not_found(error): + """Turn Flask 404 errors into SCIM error responses.""" + scim_error = Error(status=404, detail=str(error.description)) + return scim_error.model_dump_json(), HTTPStatus.NOT_FOUND + + +@bp.errorhandler(ValueError) +def handle_value_error(error): + """Turn uniqueness errors into SCIM 409 responses.""" + scim_error = UniquenessException(detail=str(error)).to_error() + return scim_error.model_dump_json(), HTTPStatus.CONFLICT +# -- error-handlers-end -- +# -- refinements-end -- + + +# -- endpoints-start -- +# -- single-resource-start -- +# -- get-user-start -- +@bp.get("/Users/") +def get_user(app_record): + """Return one SCIM user.""" + scim_user = to_scim_user(app_record) + return ( + scim_user.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), + HTTPStatus.OK, + ) +# -- get-user-end -- + + +# -- patch-user-start -- +@bp.patch("/Users/") +def patch_user(app_record): + """Apply a SCIM PatchOp to an existing user.""" + scim_user = to_scim_user(app_record) + patch = PatchOp[User].model_validate( + request.get_json(), + scim_ctx=Context.RESOURCE_PATCH_REQUEST, + ) + patch.patch(scim_user) + + updated_record = from_scim_user(scim_user) + save_record(updated_record) + + return ( + scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE), + HTTPStatus.OK, + ) +# -- patch-user-end -- + + +# -- delete-user-start -- +@bp.delete("/Users/") +def delete_user(app_record): + """Delete an existing user.""" + delete_record(app_record["id"]) + return "", HTTPStatus.NO_CONTENT +# -- delete-user-end -- +# -- single-resource-end -- + + +# -- collection-start -- +# -- list-users-start -- +@bp.get("/Users") +def list_users(): + """Return one page of users as a SCIM ListResponse.""" + req = SearchRequest.model_validate(request.args) + all_records = list_records() + page = all_records[req.start_index_0 : req.stop_index_0] + resources = [to_scim_user(record) for record in page] + response = ListResponse[User]( + total_results=len(all_records), + start_index=req.start_index or 1, + items_per_page=len(resources), + resources=resources, + ) + return ( + response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE), + HTTPStatus.OK, + ) +# -- list-users-end -- + + +# -- create-user-start -- +@bp.post("/Users") +def create_user(): + """Validate a SCIM creation payload and store the new user.""" + request_user = User.model_validate( + request.get_json(), + scim_ctx=Context.RESOURCE_CREATION_REQUEST, + ) + app_record = from_scim_user(request_user) + save_record(app_record) + + response_user = to_scim_user(app_record) + return ( + response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE), + HTTPStatus.CREATED, + ) +# -- create-user-end -- +# -- collection-end -- +# -- endpoints-end -- diff --git a/doc/guides/_examples/integrations.py b/doc/guides/_examples/integrations.py new file mode 100644 index 0000000..952b780 --- /dev/null +++ b/doc/guides/_examples/integrations.py @@ -0,0 +1,60 @@ +"""Framework-agnostic storage and mapping layer shared by the integration examples.""" + +from uuid import uuid4 + +from scim2_models import Meta +from scim2_models import User + +# -- storage-start -- +records = {} + + +def get_record(record_id): + """Return the record for *record_id*, raising KeyError if absent.""" + if record_id not in records: + raise KeyError(record_id) + return records[record_id] + + +def list_records(): + """Return all stored records as a list.""" + return list(records.values()) + + +def save_record(record): + """Persist *record*, raising ValueError if its userName is already taken.""" + for existing in records.values(): + if existing["id"] != record["id"] and existing["user_name"] == record["user_name"]: + raise ValueError(f"userName {record['user_name']!r} is already taken") + records[record["id"]] = record + + +def delete_record(record_id): + """Remove the record identified by *record_id*.""" + del records[record_id] +# -- storage-end -- + + +# -- mapping-start -- +def to_scim_user(record): + """Convert an application record into a SCIM User resource.""" + return User( + id=record["id"], + user_name=record["user_name"], + display_name=record.get("display_name"), + active=record.get("active", True), + emails=[User.Emails(value=record["email"])] if record.get("email") else None, + meta=Meta(resource_type="User"), + ) + + +def from_scim_user(scim_user): + """Convert a validated SCIM payload into the application shape.""" + return { + "id": scim_user.id or str(uuid4()), + "user_name": scim_user.user_name, + "display_name": scim_user.display_name, + "active": True if scim_user.active is None else scim_user.active, + "email": scim_user.emails[0].value if scim_user.emails else None, + } +# -- mapping-end -- diff --git a/doc/guides/django.rst b/doc/guides/django.rst new file mode 100644 index 0000000..7a65729 --- /dev/null +++ b/doc/guides/django.rst @@ -0,0 +1,141 @@ +Django Integration +------------------ + +This guide shows a minimal SCIM integration with `Django `_ +and :mod:`scim2_models`. +It focuses on the integration points that matter most: + +- validating incoming SCIM payloads with the right :class:`~scim2_models.Context`; +- serializing resources and collections as SCIM responses; +- exposing responses with the ``application/scim+json`` media type; +- parsing pagination parameters with :class:`~scim2_models.SearchRequest`; +- handling Django-specific concerns such as URLconfs, custom path converters, and CSRF. + +The example uses :class:`~scim2_models.User` as a concrete resource type, but the same +pattern applies to any other resource such as :class:`~scim2_models.Group`. +The storage and mapping layers are defined in the :doc:`index` section and shared across +all integration examples. +The complete runnable file is available in the `Complete example`_ section. + +.. code-block:: shell + + pip install django scim2-models + +Application setup +================= + +Start with a small response helper that sets the ``application/scim+json`` content type +on every response. + +.. literalinclude:: _examples/django_example.py + :language: python + :start-after: # -- setup-start -- + :end-before: # -- setup-end -- + +Optional Django refinements +=========================== + +The core SCIM flow only needs the endpoints below. +Django also offers a few convenient integration patterns that can keep the views shorter +and help keep framework-level errors aligned with SCIM responses. + +Django converters +^^^^^^^^^^^^^^^^^ + +Custom path converters let Django resolve route parameters before the view function is +called. +Define one converter per resource type: it maps a resource identifier to an application +record and lets Django turn lookup failures into a 404 during URL resolution. + +.. literalinclude:: _examples/django_example.py + :language: python + :start-after: # -- converters-start -- + :end-before: # -- converters-end -- + +Validation helper +^^^^^^^^^^^^^^^^^ + +The validation helper keeps Pydantic validation errors aligned with SCIM responses. + +.. literalinclude:: _examples/django_example.py + :language: python + :start-after: # -- validation-helper-start -- + :end-before: # -- validation-helper-end -- + +If :meth:`~scim2_models.Resource.model_validate` or +:meth:`~scim2_models.PatchOp.model_validate` fails, the views below catch the +:class:`~pydantic.ValidationError` and return a SCIM :class:`~scim2_models.Error` +response. + +Uniqueness error helper +^^^^^^^^^^^^^^^^^^^^^^^ + +``scim_uniqueness_error`` catches the ``ValueError`` raised by ``save_record`` and returns a +409 with ``scimType: uniqueness`` using :class:`~scim2_models.UniquenessException`. + +.. literalinclude:: _examples/django_example.py + :language: python + :start-after: # -- uniqueness-helper-start -- + :end-before: # -- uniqueness-helper-end -- + +Error handler +^^^^^^^^^^^^^ + +Django does not produce SCIM-formatted 404 responses by default. +Defining ``handler404`` in the URLconf module overrides this behaviour. +Note that Django only calls ``handler404`` when ``DEBUG`` is ``False``. + +.. literalinclude:: _examples/django_example.py + :language: python + :start-after: # -- error-handler-start -- + :end-before: # -- error-handler-end -- + +Endpoints +========= + +Django's CSRF middleware is enabled by default. +The views below use ``@csrf_exempt`` to accept JSON API requests directly. + +The views serve ``/Users``, but the same structure applies to any resource type: +replace the mapping helpers, the model class, and the URL prefix to expose ``/Groups`` or +any other collection. + +Single resource +^^^^^^^^^^^^^^^ + +``UserView`` handles ``GET``, ``PATCH`` and ``DELETE`` on ``/Users/``. +For ``GET``, convert the native record to a SCIM resource and serialize with +:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. +For ``DELETE``, remove the record and return an empty 204 response. +For ``PATCH``, validate the payload with :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`, +apply it with :meth:`~scim2_models.PatchOp.patch` (generic, works with any resource type), +convert back to native and persist, then serialize with +:attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE`. + +.. literalinclude:: _examples/django_example.py + :language: python + :start-after: # -- single-resource-start -- + :end-before: # -- single-resource-end -- + +Collection +^^^^^^^^^^ + +``UsersView`` handles ``GET /Users`` and ``POST /Users``. +For ``GET``, parse pagination parameters with :class:`~scim2_models.SearchRequest`, slice +the store, then wrap the page in a :class:`~scim2_models.ListResponse` serialized with +:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. +For ``POST``, validate the creation payload with +:attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`, persist the record, then serialize +with :attr:`~scim2_models.Context.RESOURCE_CREATION_RESPONSE`. +The ``urlpatterns`` list wires both views to their routes. + +.. literalinclude:: _examples/django_example.py + :language: python + :start-after: # -- collection-start -- + :end-before: # -- collection-end -- + +Complete example +================ + +.. literalinclude:: _examples/django_example.py + :language: python diff --git a/doc/guides/flask.rst b/doc/guides/flask.rst new file mode 100644 index 0000000..c95474c --- /dev/null +++ b/doc/guides/flask.rst @@ -0,0 +1,144 @@ +Flask Integration +----------------- + +This guide shows a minimal SCIM server built with `Flask `_ +and :mod:`scim2_models`. +It focuses on: + +- validate incoming SCIM payloads with the right :class:`~scim2_models.Context`; +- serialize resources and collections as SCIM responses; +- expose responses with the ``application/scim+json`` media type; +- convert validation errors into SCIM :class:`~scim2_models.Error` payloads. + +The example uses :class:`~scim2_models.User` as a concrete resource type, but the same +pattern applies to any other resource such as :class:`~scim2_models.Group`. +The storage and mapping layers are defined in the :doc:`index` section and shared across +all integration examples. +The complete runnable file is available in the `Complete example`_ section. + +.. code-block:: shell + + pip install flask scim2-models + +Blueprint setup +=============== + +Start with a Flask blueprint. +The SCIM specifications indicates that the responses content type must be ``application/scim+json``, +so lets enforce this on all the blueprint views with :meth:`~flask.Blueprint.after_request`. + +.. literalinclude:: _examples/flask_example.py + :language: python + :start-after: # -- setup-start -- + :end-before: # -- setup-end -- + +Optional Flask refinements +========================== + +The core SCIM flow only needs the blueprint and the endpoints below. +Flask also offers a few convenient integration patterns that can keep the views shorter and +help keep framework-level errors aligned with SCIM responses. + +Flask converters +^^^^^^^^^^^^^^^^ + +Converters let Flask resolve route parameters before the view function is called. +Define one converter per resource type: it maps a resource identifier to an application +record and lets Flask handle the not-found case before entering the view. +``bp.record_once`` registers the converter on the app when the blueprint is attached. + +.. literalinclude:: _examples/flask_example.py + :language: python + :start-after: # -- converters-start -- + :end-before: # -- converters-end -- + +Error handlers +^^^^^^^^^^^^^^ + +The error handlers keep Pydantic validation errors and Flask 404s aligned with SCIM +responses. + +.. literalinclude:: _examples/flask_example.py + :language: python + :start-after: # -- error-handlers-start -- + :end-before: # -- error-handlers-end -- + +If :meth:`~scim2_models.Resource.model_validate`, Flask routes the +:class:`~pydantic.ValidationError` to ``handle_validation_error`` and the client receives a +SCIM :class:`~scim2_models.Error` response. +``handle_value_error`` catches the ``ValueError`` raised by ``save_record`` and returns a 409 +with ``scimType: uniqueness`` using :class:`~scim2_models.UniquenessException`. + +Endpoints +========= + +The routes below serve ``/Users``, but the same structure applies to any resource type: +replace the mapping helpers, the model class, and the URL prefix to expose ``/Groups`` or +any other collection. + +GET /Users/ +^^^^^^^^^^^^^^^ + +Convert the native record to a SCIM resource with your mapping helper, then serialize with +:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. + +.. literalinclude:: _examples/flask_example.py + :language: python + :start-after: # -- get-user-start -- + :end-before: # -- get-user-end -- + +DELETE /Users/ +^^^^^^^^^^^^^^^^^^ + +Remove the record from the store and return an empty 204 response. +No SCIM serialization is needed. + +.. literalinclude:: _examples/flask_example.py + :language: python + :start-after: # -- delete-user-start -- + :end-before: # -- delete-user-end -- + +PATCH /Users/ +^^^^^^^^^^^^^^^^^ + +Validate the patch payload with :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`, +apply it to a SCIM conversion of the native record with :meth:`~scim2_models.PatchOp.patch`, +convert back to native and persist, then serialize the result with +:attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE`. +:class:`~scim2_models.PatchOp` is generic and works with any resource type. + +.. literalinclude:: _examples/flask_example.py + :language: python + :start-after: # -- patch-user-start -- + :end-before: # -- patch-user-end -- + + +GET /Users +^^^^^^^^^^ + +Parse pagination parameters with :class:`~scim2_models.SearchRequest`, slice the store +accordingly, then wrap the page in a :class:`~scim2_models.ListResponse` serialized with +:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. + +.. literalinclude:: _examples/flask_example.py + :language: python + :start-after: # -- list-users-start -- + :end-before: # -- list-users-end -- + +POST /Users +^^^^^^^^^^^ + +Validate the creation payload with :attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`, +convert to native and persist, then serialize the created resource with +:attr:`~scim2_models.Context.RESOURCE_CREATION_RESPONSE`. + +.. literalinclude:: _examples/flask_example.py + :language: python + :start-after: # -- create-user-start -- + :end-before: # -- create-user-end -- + +Complete example +================ + +.. literalinclude:: _examples/flask_example.py + :language: python diff --git a/doc/guides/index.rst b/doc/guides/index.rst new file mode 100644 index 0000000..bbef13e --- /dev/null +++ b/doc/guides/index.rst @@ -0,0 +1,48 @@ +Integrations +============ + +This section shows how to integrate scim2-models with your web framework to build a SCIM server. + +Storage layer +------------- + +For the sake of simplicity, all integration example will use the following simplistic storage layer. +It wraps an in-memory dictionary and enforces business constraints such as ``userName`` +uniqueness. +In real applications, you will replace these functions with ORM calls (Django ORM, SQLAlchemy etc.), and adapt the code accordingly. + +.. literalinclude:: _examples/integrations.py + :language: python + :caption: Minimalist storage layer + :start-after: # -- storage-start -- + :end-before: # -- storage-end -- + +Mapping application data to SCIM +--------------------------------- + +scim2-models suppose that your application storage layer has its own internal model and does not use SCIM models +internally. +You need mapping helpers that convert between your application representation and the SCIM +resource exposed over HTTP — here :class:`~scim2_models.User`, but the same approach works +for :class:`~scim2_models.Group` or any other resource type. + +.. literalinclude:: _examples/integrations.py + :language: python + :caption: Example of serialization and deserialization between scim2 and custom model representation + :start-after: # -- mapping-start -- + :end-before: # -- mapping-end -- + +This separation keeps the HTTP layer simple. +The views work with SCIM resources, while the rest of the application can keep its own +representation. + +Web frameworks +-------------- + +Those sections show how to process incoming SCIM HTTP requests, and which response to produce. + +.. toctree:: + :maxdepth: 1 + + flask + django diff --git a/doc/index.rst b/doc/index.rst index 1630962..a514ef6 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -8,6 +8,7 @@ Table of contents :maxdepth: 2 tutorial + guides/index reference contributing changelog diff --git a/pyproject.toml b/pyproject.toml index fb04741..ddfc25a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ module-root = "" [dependency-groups] dev = [ + "django>=5.1", + "flask>=3.0.0", "mypy>=1.13.0", "pytest>=8.2.1", "pytest-cov>=6.0.0", diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py new file mode 100644 index 0000000..f840aa1 --- /dev/null +++ b/tests/test_doc_examples.py @@ -0,0 +1,142 @@ +import json + +import pytest + +flask = pytest.importorskip("flask") +django = pytest.importorskip("django") + + +def create_flask_app(): + from flask import Flask + + from doc.guides._examples import flask_example + + app = Flask(__name__) + app.register_blueprint(flask_example.bp) + return app + + +def test_flask_example_smoke(): + from doc.guides._examples import integrations + + integrations.records.clear() + app = create_flask_app() + client = app.test_client() + + create_response = client.post( + "/scim/v2/Users", + json={ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen@example.com", + "displayName": "Barbara Jensen", + "active": True, + "emails": [{"value": "bjensen@example.com"}], + }, + ) + assert create_response.status_code == 201 + assert create_response.headers["Content-Type"] == "application/scim+json" + user_id = create_response.get_json()["id"] + + get_response = client.get(f"/scim/v2/Users/{user_id}") + assert get_response.status_code == 200 + assert get_response.get_json()["userName"] == "bjensen@example.com" + + patch_response = client.patch( + f"/scim/v2/Users/{user_id}", + json={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "replace", "path": "displayName", "value": "Babs"}], + }, + ) + assert patch_response.status_code == 200 + assert patch_response.get_json()["displayName"] == "Babs" + + list_response = client.get("/scim/v2/Users?startIndex=1&count=1") + assert list_response.status_code == 200 + assert list_response.get_json()["totalResults"] == 1 + + duplicate_response = client.post( + "/scim/v2/Users", + json={ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen@example.com", + }, + ) + assert duplicate_response.status_code == 409 + assert duplicate_response.get_json()["scimType"] == "uniqueness" + + +def test_django_example_smoke(): + from django.conf import settings + + settings.configure( + DEBUG=True, + SECRET_KEY="test-secret-key", + ROOT_URLCONF="doc.guides._examples.django_example", + ALLOWED_HOSTS=["testserver"], + MIDDLEWARE=[], + ) + django.setup() + + from django.test import Client + from django.test import override_settings + + from doc.guides._examples import integrations + + integrations.records.clear() + + with override_settings(ROOT_URLCONF="doc.guides._examples.django_example"): + client = Client() + + create_response = client.post( + "/scim/v2/Users", + data=json.dumps( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen@example.com", + "displayName": "Barbara Jensen", + "active": True, + "emails": [{"value": "bjensen@example.com"}], + } + ), + content_type="application/scim+json", + ) + assert create_response.status_code == 201 + assert create_response.headers["Content-Type"] == "application/scim+json" + user_id = json.loads(create_response.content)["id"] + + get_response = client.get(f"/scim/v2/Users/{user_id}") + assert get_response.status_code == 200 + assert json.loads(get_response.content)["userName"] == "bjensen@example.com" + + patch_response = client.patch( + f"/scim/v2/Users/{user_id}", + data=json.dumps( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + {"op": "replace", "path": "displayName", "value": "Babs"} + ], + } + ), + content_type="application/scim+json", + ) + assert patch_response.status_code == 200 + assert json.loads(patch_response.content)["displayName"] == "Babs" + + list_response = client.get("/scim/v2/Users?startIndex=1&count=1") + assert list_response.status_code == 200 + assert json.loads(list_response.content)["totalResults"] == 1 + + duplicate_response = client.post( + "/scim/v2/Users", + data=json.dumps( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "bjensen@example.com", + } + ), + content_type="application/scim+json", + ) + assert duplicate_response.status_code == 409 + assert json.loads(duplicate_response.content)["scimType"] == "uniqueness" diff --git a/uv.lock b/uv.lock index 6663e54..25205c4 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + [[package]] name = "autodoc-pydantic" version = "2.2.0" @@ -49,6 +61,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "cachetools" version = "7.0.5" @@ -172,6 +193,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -308,6 +341,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "django" +version = "5.2.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" }, +] + +[[package]] +name = "django" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -360,7 +428,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { 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 = [ @@ -376,6 +444,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -403,6 +488,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1133,6 +1227,9 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "django", version = "5.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "flask" }, { name = "mypy" }, { name = "prek" }, { name = "pytest" }, @@ -1157,6 +1254,8 @@ requires-dist = [{ name = "pydantic", extras = ["email"], specifier = ">=2.12.0" [package.metadata.requires-dev] dev = [ + { name = "django", specifier = ">=5.1" }, + { name = "flask", specifier = ">=3.0.0" }, { name = "mypy", specifier = ">=1.13.0" }, { name = "prek", specifier = ">=0.1.0" }, { name = "pytest", specifier = ">=8.2.1" }, @@ -1398,6 +1497,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -1530,6 +1638,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -1581,6 +1698,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/43/76ded108b296a49f52de6bac5192ca1c4be84e886f9b5c9ba8427d9694fd/werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351", size = 875700, upload-time = "2026-03-24T01:08:07.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b2/0bba9bbb4596d2d2f285a16c2ab04118f6b957d8441566e1abb892e6a6b2/werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f", size = 226295, upload-time = "2026-03-24T01:08:06.133Z" }, +] + [[package]] name = "wheel" version = "0.46.3"