Django#

This guide shows a minimal SCIM integration with Django and scim2_models. It focuses on the integration points that matter most:

  • validating incoming SCIM payloads with the right Context;

  • serializing resources and collections as SCIM responses;

  • exposing responses with the application/scim+json media type;

  • parsing pagination parameters with SearchRequest;

  • handling Django-specific concerns such as URLconfs, custom path converters, and CSRF.

The example uses User as a concrete resource type, but the same pattern applies to any other resource such as Group. The storage and mapping layers are defined in the Integrations section and shared across all integration examples. The complete runnable file is available in the Complete example section.

pip install django scim2-models

Application setup#

Start with a SCIMView base class that all SCIM views inherit from. It overrides the dispatch of View to centralise cross-cutting concerns: the application/scim+json content type, ETag extraction from meta.version, If-None-Match (304) on reads, and If-Match (412) on writes. The @csrf_exempt decorator is applied once on the base class.

class SCIMJsonResponse(JsonResponse):
    """JSON response with the ``application/scim+json`` media type.

    Keeps a reference to the original data dict in :attr:`scim_data` so that
    ``dispatch()`` can inspect it without re-parsing the serialised body.
    """

    def __init__(self, data, **kwargs):
        self.scim_data = data
        kwargs.setdefault("content_type", "application/scim+json")
        super().__init__(data, **kwargs)


@method_decorator(csrf_exempt, name="dispatch")
class SCIMView(View):
    """Base view for SCIM endpoints.

    Extracts the ``ETag`` header from ``meta.version``, handles
    ``If-None-Match`` (304) on GET, and checks ``If-Match`` (412) on
    write operations.
    """

    # -- etag-start --
    def dispatch(self, request, *args, **kwargs):
        """Dispatch with ETag handling."""
        if request.method in ("PUT", "PATCH", "DELETE"):
            app_record = kwargs.get("app_record")
            if app_record is not None:
                if_match = request.META.get("HTTP_IF_MATCH")
                if if_match and if_match.strip() != "*":
                    etag = make_etag(app_record)
                    if etag not in parse_etags(if_match):
                        scim_error = Error(status=412, detail="ETag mismatch")
                        return SCIMJsonResponse(
                            scim_error.model_dump(), status=412
                        )

        response = super().dispatch(request, *args, **kwargs)

        data = getattr(response, "scim_data", None)
        if data is None:
            return response

        if meta := data.get("meta"):
            if version := meta.get("version"):
                response["ETag"] = version

        if request.method == "GET" and (etag := response.get("ETag")):
            if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
            if if_none_match and etag in parse_etags(if_none_match):
                return HttpResponse(status=HTTPStatus.NOT_MODIFIED)

        return response
    # -- etag-end --


def resource_location(request, app_record):
    """Return the canonical URL for a user record."""
    return request.build_absolute_uri(
        reverse("scim_user", kwargs={"app_record": app_record})
    )

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.

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")

Validation helper#

The validation helper keeps Pydantic validation errors aligned with SCIM responses.

def scim_validation_error(error):
    """Turn Pydantic validation errors into a SCIM error response."""
    scim_error = Error.from_validation_error(error.errors()[0])
    return SCIMJsonResponse(scim_error.model_dump(), status=scim_error.status)

The views below hand request.body straight to model_validate_json(), which takes a scim_ctx like the other validation methods, so the payload never has to be decoded first. A malformed JSON body raises a ValidationError like any other validation failure, so this single handler turns it into a SCIM Error response.

SCIM exception helper#

scim_exception_error converts any SCIMException (uniqueness, mutability, …) into a SCIM error response.

def scim_exception_error(error):
    """Turn SCIM exceptions into a SCIM error response."""
    scim_error = error.to_error()
    return SCIMJsonResponse(scim_error.model_dump(), status=scim_error.status)

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.

def handler404(request, exception):
    """Turn Django 404 errors into SCIM error responses."""
    scim_error = Error(status=404, detail=str(exception))
    return SCIMJsonResponse(
        scim_error.model_dump(),
        status=HTTPStatus.NOT_FOUND,
    )

Endpoints#

Django’s CSRF middleware is enabled by default. SCIMView applies @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, PUT, PATCH and DELETE on /Users/<id>. For GET, parse query parameters with ResponseParameters to honour the attributes and excludedAttributes query parameters, convert the native record to a SCIM resource, and serialize with RESOURCE_QUERY_RESPONSE. For DELETE, remove the record and return an empty 204 response. For PUT, validate the full replacement payload with RESOURCE_REPLACEMENT_REQUEST, then call replace() to verify that immutable attributes have not been modified. Convert back to native and persist, then serialize with RESOURCE_REPLACEMENT_RESPONSE. For PATCH, validate the payload with RESOURCE_PATCH_REQUEST, apply it with patch() (generic, works with any resource type), convert back to native and persist, then serialize with RESOURCE_PATCH_RESPONSE.

class UserView(SCIMView):
    """Handle GET, PUT, PATCH and DELETE on one SCIM user resource."""

    def get(self, request, app_record):
        try:
            req = ResponseParameters.model_validate(request.GET.dict())
        except ValidationError as error:
            return scim_validation_error(error)

        scim_user = to_scim_user(app_record, resource_location(request, app_record))
        return SCIMJsonResponse(
            scim_user.model_dump(
                scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
                attributes=req.attributes,
                excluded_attributes=req.excluded_attributes,
            )
        )

    def delete(self, request, app_record):
        delete_record(app_record["id"])
        return HttpResponse(status=HTTPStatus.NO_CONTENT)

    def put(self, request, app_record):
        req = ResponseParameters.model_validate(request.GET.dict())
        existing_user = to_scim_user(app_record, resource_location(request, app_record))
        try:
            replacement = User.model_validate_json(
                request.body,
                scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
            )
            replacement.replace(existing_user)
        except ValidationError as error:
            return scim_validation_error(error)
        except SCIMException as error:
            return scim_exception_error(error)

        updated_record = from_scim_user(replacement)
        try:
            save_record(updated_record)
        except SCIMException as error:
            return scim_exception_error(error)

        response_user = to_scim_user(
            updated_record, resource_location(request, updated_record)
        )
        return SCIMJsonResponse(
            response_user.model_dump(
                scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
                attributes=req.attributes,
                excluded_attributes=req.excluded_attributes,
            )
        )

    def patch(self, request, app_record):
        req = ResponseParameters.model_validate(request.GET.dict())
        try:
            patch = PatchOp[User].model_validate_json(
                request.body,
                scim_ctx=Context.RESOURCE_PATCH_REQUEST,
            )
        except ValidationError as error:
            return scim_validation_error(error)

        scim_user = to_scim_user(app_record, resource_location(request, app_record))
        patch.patch(scim_user)

        updated_record = from_scim_user(scim_user)
        try:
            save_record(updated_record)
        except SCIMException as error:
            return scim_exception_error(error)

        return SCIMJsonResponse(
            scim_user.model_dump(
                scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
                attributes=req.attributes,
                excluded_attributes=req.excluded_attributes,
            )
        )

Collection#

UsersView handles GET /Users and POST /Users. For GET, parse the query parameters with SearchRequest[User], order and page the mapped resources as described in Ordering and paging collections, then wrap the page in a ListResponse serialized with RESOURCE_QUERY_RESPONSE. req.attributes and req.excluded_attributes are passed to model_dump() to apply the attributes and excludedAttributes query parameters to each embedded resource. For POST, validate the creation payload with RESOURCE_CREATION_REQUEST, persist the record, then serialize with RESOURCE_CREATION_RESPONSE.

def users_response(request, req, scim_ctx):
    """Return one page of users as a serialized SCIM ListResponse.

    A query applies to the SCIM representation rather than to the stored
    records, so the store is mapped before it is ordered and paginated.

    :param request: The incoming request, used to build resource locations.
    :param req: The parsed query, whichever verb carried it.
    :param scim_ctx: The context to serialize the response in.
    """
    users = [
        to_scim_user(record, resource_location(request, record))
        for record in list_records()
    ]
    total, page = page_of(users, req)
    response = ListResponse[User](
        total_results=total,
        start_index=req.start_index or 1,
        items_per_page=len(page),
        resources=page,
    )
    return SCIMJsonResponse(
        response.model_dump(
            scim_ctx=scim_ctx,
            attributes=req.attributes,
            excluded_attributes=req.excluded_attributes,
        )
    )


class UsersView(SCIMView):
    """Handle GET and POST on the SCIM users collection."""

    def get(self, request):
        try:
            req = SearchRequest[User].model_validate(request.GET.dict())
        except ValidationError as error:
            return scim_validation_error(error)

        return users_response(request, req, Context.RESOURCE_QUERY_RESPONSE)

    def post(self, request):
        req = ResponseParameters.model_validate(request.GET.dict())
        try:
            request_user = User.model_validate_json(
                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 SCIMException as error:
            return scim_exception_error(error)

        response_user = to_scim_user(app_record, resource_location(request, app_record))
        return SCIMJsonResponse(
            response_user.model_dump(
                scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
                attributes=req.attributes,
                excluded_attributes=req.excluded_attributes,
            ),
            status=HTTPStatus.CREATED,
        )


POST /Users/.search#

RFC7644 §3.4.3 lets a client send the same query in a body rather than on the URL, by appending /.search to any endpoint. The parameters are the ones of GET /Users, so the two verbs differ only in the parsing: validate the body with SEARCH_REQUEST and serialize the response with SEARCH_RESPONSE.

class UsersSearchView(SCIMView):
    """Answer the same query as GET /Users, with the parameters in the body."""

    def post(self, request):
        try:
            req = SearchRequest[User].model_validate_json(
                request.body, scim_ctx=Context.SEARCH_REQUEST
            )
        except ValidationError as error:
            return scim_validation_error(error)

        return users_response(request, req, Context.SEARCH_RESPONSE)

POST /.search#

The same extension on the server root queries every resource type the server serves, as RFC7644 §3.4.2.1 describes: “a query against a server root indicates that all resources within the server SHALL be included, subject to filtering”. The endpoint gathers each type it serves and answers with a ListResponse[User | Group].

These guides expose no /Groups endpoints; the groups the root query gathers are read-only fixtures, enough to show a heterogeneous collection.

Note that a root query is also where RFC7644 §3.4.2.1 allows a server to refuse a request whose result set would be too large, with a tooMany error.

class RootSearchView(SCIMView):
    """Query every resource type the server serves.

    :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` has a root query cover them
    all, so the request is parameterised with a union and the response gathers
    groups alongside users. These guides expose no ``/Groups`` endpoint; the
    groups they gather are read-only fixtures.
    """

    def post(self, request):
        try:
            req = SearchRequest[User | Group].model_validate_json(
                request.body, scim_ctx=Context.SEARCH_REQUEST
            )
        except ValidationError as error:
            return scim_validation_error(error)

        resources = [
            to_scim_user(record, resource_location(request, record))
            for record in list_records()
        ]
        resources += [to_scim_group(record) for record in list_group_records()]
        total, page = page_of(resources, req)
        response = ListResponse[User | Group](
            total_results=total,
            start_index=req.start_index or 1,
            items_per_page=len(page),
            resources=page,
        )
        return SCIMJsonResponse(
            response.model_dump(
                scim_ctx=Context.SEARCH_RESPONSE,
                attributes=req.attributes,
                excluded_attributes=req.excluded_attributes,
            )
        )

Routes#

The urlpatterns list wires every view to its route, /Users/.search before /Users/<id> so that the resource converter does not read .search as an identifier.

# ``/Users/.search`` comes first: the ``user`` converter would otherwise read
# ``.search`` as a resource id.
urlpatterns = [
    path("scim/v2/Users", UsersView.as_view(), name="scim_users"),
    path("scim/v2/Users/.search", UsersSearchView.as_view(), name="scim_users_search"),
    path("scim/v2/.search", RootSearchView.as_view(), name="scim_root_search"),
    path("scim/v2/Users/<user:app_record>", UserView.as_view(), name="scim_user"),
]

Resource versioning (ETags)#

SCIM supports resource versioning through HTTP ETags (RFC 7644 §3.14). All ETag handling is centralised in SCIMView.dispatch():

  • The ETag response header is extracted from meta.version in the JSON body.

  • On GET, If-None-Match is checked to return 304 Not Modified when the client already has the current version.

  • On write operations (PUT, PATCH, DELETE), the If-Match header is checked before calling the handler. If the client’s ETag does not match, a 412 Precondition Failed SCIM error is returned.

make_etag computes a weak ETag from each record and populates version.

    def dispatch(self, request, *args, **kwargs):
        """Dispatch with ETag handling."""
        if request.method in ("PUT", "PATCH", "DELETE"):
            app_record = kwargs.get("app_record")
            if app_record is not None:
                if_match = request.META.get("HTTP_IF_MATCH")
                if if_match and if_match.strip() != "*":
                    etag = make_etag(app_record)
                    if etag not in parse_etags(if_match):
                        scim_error = Error(status=412, detail="ETag mismatch")
                        return SCIMJsonResponse(
                            scim_error.model_dump(), status=412
                        )

        response = super().dispatch(request, *args, **kwargs)

        data = getattr(response, "scim_data", None)
        if data is None:
            return response

        if meta := data.get("meta"):
            if version := meta.get("version"):
                response["ETag"] = version

        if request.method == "GET" and (etag := response.get("ETag")):
            if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
            if if_none_match and etag in parse_etags(if_none_match):
                return HttpResponse(status=HTTPStatus.NOT_MODIFIED)

        return response

Tip

With Django ORM, a UUIDField regenerated on every save() provides a collision-free ETag value without relying on clock precision:

import uuid

from django.db import models

class UserModel(models.Model):
    version = models.UUIDField(default=uuid.uuid4)

    def save(self, **kwargs):
        self.version = uuid.uuid4()
        super().save(**kwargs)

Discovery endpoints#

SCIM defines three read-only endpoints that let clients discover the server’s capabilities and the resources it exposes (RFC 7644 §4). The shared discovery helpers that build Schema, ResourceType and ServiceProviderConfig objects are defined in the Integrations section.

GET /Schemas and GET /Schemas/<id>#

Return all Schema objects or look one up by its URI. Schemas are built automatically from resource models with to_schema(). The collection endpoint parses pagination parameters with SearchRequest, following the same pattern as GET /Users.

class SchemasView(SCIMView):
    """Handle GET on the SCIM schemas collection."""

    def get(self, request):
        try:
            req = SearchRequest[Schema].model_validate(request.GET.dict())
        except ValidationError as error:
            return scim_validation_error(error)

        total, page = page_of(get_schemas(), req)
        response = ListResponse[Schema](
            total_results=total,
            start_index=req.start_index or 1,
            items_per_page=len(page),
            resources=page,
        )
        return SCIMJsonResponse(
            response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
        )


class SchemaView(SCIMView):
    """Handle GET on a single SCIM schema."""

    def get(self, request, schema_id):
        try:
            schema = get_schema(schema_id)
        except KeyError:
            scim_error = Error(status=404, detail=f"Schema {schema_id!r} not found")
            return SCIMJsonResponse(
                scim_error.model_dump(), status=HTTPStatus.NOT_FOUND
            )
        return SCIMJsonResponse(
            schema.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
        )

GET /ResourceTypes and GET /ResourceTypes/<id>#

Return all ResourceType objects or look one up by its identifier. Resource types are built automatically from resource models with from_resource(). The collection endpoint parses pagination parameters with SearchRequest, following the same pattern as GET /Users.

class ResourceTypesView(SCIMView):
    """Handle GET on the SCIM resource types collection."""

    def get(self, request):
        try:
            req = SearchRequest[ResourceType].model_validate(request.GET.dict())
        except ValidationError as error:
            return scim_validation_error(error)

        total, page = page_of(get_resource_types(), req)
        response = ListResponse[ResourceType](
            total_results=total,
            start_index=req.start_index or 1,
            items_per_page=len(page),
            resources=page,
        )
        return SCIMJsonResponse(
            response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
        )


class ResourceTypeView(SCIMView):
    """Handle GET on a single SCIM resource type."""

    def get(self, request, resource_type_id):
        try:
            rt = get_resource_type(resource_type_id)
        except KeyError:
            scim_error = Error(
                status=404, detail=f"ResourceType {resource_type_id!r} not found"
            )
            return SCIMJsonResponse(
                scim_error.model_dump(), status=HTTPStatus.NOT_FOUND
            )
        return SCIMJsonResponse(
            rt.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
        )

GET /ServiceProviderConfig#

Return the ServiceProviderConfig singleton that describes the features the server supports (patch, bulk, filtering, etc.).

class ServiceProviderConfigView(SCIMView):
    """Handle GET on the SCIM service provider configuration."""

    def get(self, request):
        return SCIMJsonResponse(
            service_provider_config.model_dump(
                scim_ctx=Context.RESOURCE_QUERY_RESPONSE
            )
        )

The discovery_urlpatterns list wires the discovery views to their routes. Merge it with the resource urlpatterns in your root URLconf.

Complete example#

from http import HTTPStatus

from django.http import HttpResponse
from django.http import JsonResponse
from django.urls import path
from django.urls import register_converter
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import parse_etags
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 Group
from scim2_models import PatchOp
from scim2_models import ResourceType
from scim2_models import ResponseParameters
from scim2_models import Schema
from scim2_models import SCIMException
from scim2_models import SearchRequest
from scim2_models import User

from .integrations import delete_record
from .integrations import from_scim_user
from .integrations import get_record
from .integrations import get_resource_type
from .integrations import get_resource_types
from .integrations import get_schema
from .integrations import get_schemas
from .integrations import list_records
from .integrations import list_group_records
from .integrations import page_of
from .integrations import make_etag
from .integrations import save_record
from .integrations import service_provider_config
from .integrations import to_scim_user
from .integrations import to_scim_group


# -- setup-start --
class SCIMJsonResponse(JsonResponse):
    """JSON response with the ``application/scim+json`` media type.

    Keeps a reference to the original data dict in :attr:`scim_data` so that
    ``dispatch()`` can inspect it without re-parsing the serialised body.
    """

    def __init__(self, data, **kwargs):
        self.scim_data = data
        kwargs.setdefault("content_type", "application/scim+json")
        super().__init__(data, **kwargs)


@method_decorator(csrf_exempt, name="dispatch")
class SCIMView(View):
    """Base view for SCIM endpoints.

    Extracts the ``ETag`` header from ``meta.version``, handles
    ``If-None-Match`` (304) on GET, and checks ``If-Match`` (412) on
    write operations.
    """

    # -- etag-start --
    def dispatch(self, request, *args, **kwargs):
        """Dispatch with ETag handling."""
        if request.method in ("PUT", "PATCH", "DELETE"):
            app_record = kwargs.get("app_record")
            if app_record is not None:
                if_match = request.META.get("HTTP_IF_MATCH")
                if if_match and if_match.strip() != "*":
                    etag = make_etag(app_record)
                    if etag not in parse_etags(if_match):
                        scim_error = Error(status=412, detail="ETag mismatch")
                        return SCIMJsonResponse(
                            scim_error.model_dump(), status=412
                        )

        response = super().dispatch(request, *args, **kwargs)

        data = getattr(response, "scim_data", None)
        if data is None:
            return response

        if meta := data.get("meta"):
            if version := meta.get("version"):
                response["ETag"] = version

        if request.method == "GET" and (etag := response.get("ETag")):
            if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
            if if_none_match and etag in parse_etags(if_none_match):
                return HttpResponse(status=HTTPStatus.NOT_MODIFIED)

        return response
    # -- etag-end --


def resource_location(request, app_record):
    """Return the canonical URL for a user record."""
    return request.build_absolute_uri(
        reverse("scim_user", kwargs={"app_record": app_record})
    )
# -- 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 SCIMJsonResponse(scim_error.model_dump(), status=scim_error.status)
# -- validation-helper-end --


# -- scim-exception-helper-start --
def scim_exception_error(error):
    """Turn SCIM exceptions into a SCIM error response."""
    scim_error = error.to_error()
    return SCIMJsonResponse(scim_error.model_dump(), status=scim_error.status)
# -- scim-exception-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 SCIMJsonResponse(
        scim_error.model_dump(),
        status=HTTPStatus.NOT_FOUND,
    )
# -- error-handler-end --
# -- refinements-end --


# -- endpoints-start --
# -- single-resource-start --
class UserView(SCIMView):
    """Handle GET, PUT, PATCH and DELETE on one SCIM user resource."""

    def get(self, request, app_record):
        try:
            req = ResponseParameters.model_validate(request.GET.dict())
        except ValidationError as error:
            return scim_validation_error(error)

        scim_user = to_scim_user(app_record, resource_location(request, app_record))
        return SCIMJsonResponse(
            scim_user.model_dump(
                scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
                attributes=req.attributes,
                excluded_attributes=req.excluded_attributes,
            )
        )

    def delete(self, request, app_record):
        delete_record(app_record["id"])
        return HttpResponse(status=HTTPStatus.NO_CONTENT)

    def put(self, request, app_record):
        req = ResponseParameters.model_validate(request.GET.dict())
        existing_user = to_scim_user(app_record, resource_location(request, app_record))
        try:
            replacement = User.model_validate_json(
                request.body,
                scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
            )
            replacement.replace(existing_user)
        except ValidationError as error:
            return scim_validation_error(error)
        except SCIMException as error:
            return scim_exception_error(error)

        updated_record = from_scim_user(replacement)
        try:
            save_record(updated_record)
        except SCIMException as error:
            return scim_exception_error(error)

        response_user = to_scim_user(
            updated_record, resource_location(request, updated_record)
        )
        return SCIMJsonResponse(
            response_user.model_dump(
                scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
                attributes=req.attributes,
                excluded_attributes=req.excluded_attributes,
            )
        )

    def patch(self, request, app_record):
        req = ResponseParameters.model_validate(request.GET.dict())
        try:
            patch = PatchOp[User].model_validate_json(
                request.body,
                scim_ctx=Context.RESOURCE_PATCH_REQUEST,
            )
        except ValidationError as error:
            return scim_validation_error(error)

        scim_user = to_scim_user(app_record, resource_location(request, app_record))
        patch.patch(scim_user)

        updated_record = from_scim_user(scim_user)
        try:
            save_record(updated_record)
        except SCIMException as error:
            return scim_exception_error(error)

        return SCIMJsonResponse(
            scim_user.model_dump(
                scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
                attributes=req.attributes,
                excluded_attributes=req.excluded_attributes,
            )
        )
# -- single-resource-end --


# -- collection-start --
def users_response(request, req, scim_ctx):
    """Return one page of users as a serialized SCIM ListResponse.

    A query applies to the SCIM representation rather than to the stored
    records, so the store is mapped before it is ordered and paginated.

    :param request: The incoming request, used to build resource locations.
    :param req: The parsed query, whichever verb carried it.
    :param scim_ctx: The context to serialize the response in.
    """
    users = [
        to_scim_user(record, resource_location(request, record))
        for record in list_records()
    ]
    total, page = page_of(users, req)
    response = ListResponse[User](
        total_results=total,
        start_index=req.start_index or 1,
        items_per_page=len(page),
        resources=page,
    )
    return SCIMJsonResponse(
        response.model_dump(
            scim_ctx=scim_ctx,
            attributes=req.attributes,
            excluded_attributes=req.excluded_attributes,
        )
    )


class UsersView(SCIMView):
    """Handle GET and POST on the SCIM users collection."""

    def get(self, request):
        try:
            req = SearchRequest[User].model_validate(request.GET.dict())
        except ValidationError as error:
            return scim_validation_error(error)

        return users_response(request, req, Context.RESOURCE_QUERY_RESPONSE)

    def post(self, request):
        req = ResponseParameters.model_validate(request.GET.dict())
        try:
            request_user = User.model_validate_json(
                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 SCIMException as error:
            return scim_exception_error(error)

        response_user = to_scim_user(app_record, resource_location(request, app_record))
        return SCIMJsonResponse(
            response_user.model_dump(
                scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
                attributes=req.attributes,
                excluded_attributes=req.excluded_attributes,
            ),
            status=HTTPStatus.CREATED,
        )


# -- collection-end --


# -- search-users-start --
class UsersSearchView(SCIMView):
    """Answer the same query as GET /Users, with the parameters in the body."""

    def post(self, request):
        try:
            req = SearchRequest[User].model_validate_json(
                request.body, scim_ctx=Context.SEARCH_REQUEST
            )
        except ValidationError as error:
            return scim_validation_error(error)

        return users_response(request, req, Context.SEARCH_RESPONSE)
# -- search-users-end --


# -- search-root-start --
class RootSearchView(SCIMView):
    """Query every resource type the server serves.

    :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` has a root query cover them
    all, so the request is parameterised with a union and the response gathers
    groups alongside users. These guides expose no ``/Groups`` endpoint; the
    groups they gather are read-only fixtures.
    """

    def post(self, request):
        try:
            req = SearchRequest[User | Group].model_validate_json(
                request.body, scim_ctx=Context.SEARCH_REQUEST
            )
        except ValidationError as error:
            return scim_validation_error(error)

        resources = [
            to_scim_user(record, resource_location(request, record))
            for record in list_records()
        ]
        resources += [to_scim_group(record) for record in list_group_records()]
        total, page = page_of(resources, req)
        response = ListResponse[User | Group](
            total_results=total,
            start_index=req.start_index or 1,
            items_per_page=len(page),
            resources=page,
        )
        return SCIMJsonResponse(
            response.model_dump(
                scim_ctx=Context.SEARCH_RESPONSE,
                attributes=req.attributes,
                excluded_attributes=req.excluded_attributes,
            )
        )
# -- search-root-end --


# -- urls-start --
# ``/Users/.search`` comes first: the ``user`` converter would otherwise read
# ``.search`` as a resource id.
urlpatterns = [
    path("scim/v2/Users", UsersView.as_view(), name="scim_users"),
    path("scim/v2/Users/.search", UsersSearchView.as_view(), name="scim_users_search"),
    path("scim/v2/.search", RootSearchView.as_view(), name="scim_root_search"),
    path("scim/v2/Users/<user:app_record>", UserView.as_view(), name="scim_user"),
]
# -- urls-end --


# -- discovery-start --
# -- schemas-start --
class SchemasView(SCIMView):
    """Handle GET on the SCIM schemas collection."""

    def get(self, request):
        try:
            req = SearchRequest[Schema].model_validate(request.GET.dict())
        except ValidationError as error:
            return scim_validation_error(error)

        total, page = page_of(get_schemas(), req)
        response = ListResponse[Schema](
            total_results=total,
            start_index=req.start_index or 1,
            items_per_page=len(page),
            resources=page,
        )
        return SCIMJsonResponse(
            response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
        )


class SchemaView(SCIMView):
    """Handle GET on a single SCIM schema."""

    def get(self, request, schema_id):
        try:
            schema = get_schema(schema_id)
        except KeyError:
            scim_error = Error(status=404, detail=f"Schema {schema_id!r} not found")
            return SCIMJsonResponse(
                scim_error.model_dump(), status=HTTPStatus.NOT_FOUND
            )
        return SCIMJsonResponse(
            schema.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
        )
# -- schemas-end --


# -- resource-types-start --
class ResourceTypesView(SCIMView):
    """Handle GET on the SCIM resource types collection."""

    def get(self, request):
        try:
            req = SearchRequest[ResourceType].model_validate(request.GET.dict())
        except ValidationError as error:
            return scim_validation_error(error)

        total, page = page_of(get_resource_types(), req)
        response = ListResponse[ResourceType](
            total_results=total,
            start_index=req.start_index or 1,
            items_per_page=len(page),
            resources=page,
        )
        return SCIMJsonResponse(
            response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
        )


class ResourceTypeView(SCIMView):
    """Handle GET on a single SCIM resource type."""

    def get(self, request, resource_type_id):
        try:
            rt = get_resource_type(resource_type_id)
        except KeyError:
            scim_error = Error(
                status=404, detail=f"ResourceType {resource_type_id!r} not found"
            )
            return SCIMJsonResponse(
                scim_error.model_dump(), status=HTTPStatus.NOT_FOUND
            )
        return SCIMJsonResponse(
            rt.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
        )
# -- resource-types-end --


# -- service-provider-config-start --
class ServiceProviderConfigView(SCIMView):
    """Handle GET on the SCIM service provider configuration."""

    def get(self, request):
        return SCIMJsonResponse(
            service_provider_config.model_dump(
                scim_ctx=Context.RESOURCE_QUERY_RESPONSE
            )
        )
# -- service-provider-config-end --


discovery_urlpatterns = [
    path("scim/v2/Schemas", SchemasView.as_view(), name="scim_schemas"),
    path("scim/v2/Schemas/<path:schema_id>", SchemaView.as_view(), name="scim_schema"),
    path(
        "scim/v2/ResourceTypes",
        ResourceTypesView.as_view(),
        name="scim_resource_types",
    ),
    path(
        "scim/v2/ResourceTypes/<resource_type_id>",
        ResourceTypeView.as_view(),
        name="scim_resource_type",
    ),
    path(
        "scim/v2/ServiceProviderConfig",
        ServiceProviderConfigView.as_view(),
        name="scim_service_provider_config",
    ),
]
# -- discovery-end --
# -- endpoints-end --