Skip to content

Commit 1cc45fd

Browse files
authored
doc: web framework integrations (#135)
1 parent 26fcc54 commit 1cc45fd

File tree

14 files changed

+1012
-2
lines changed

14 files changed

+1012
-2
lines changed

.pre-commit-config.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ repos:
55
hooks:
66
- id: ruff-check
77
args: [--fix, --exit-non-zero-on-fix]
8+
exclude: ^doc/guides/_examples/
89
- id: ruff-format
10+
exclude: ^doc/guides/_examples/
911
- repo: https://github.com/pre-commit/pre-commit-hooks
1012
rev: v6.0.0
1113
hooks:
@@ -19,7 +21,7 @@ repos:
1921
rev: v1.19.1
2022
hooks:
2123
- id: mypy
22-
exclude: ^(tests/|conftest.py)
24+
exclude: ^(tests/|conftest\.py|doc/)
2325
additional_dependencies:
2426
- pydantic[email]>=2.7.0
2527
- repo: https://github.com/codespell-project/codespell

doc/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
intersphinx_mapping = {
4444
"python": ("https://docs.python.org/3", None),
4545
"pydantic": ("https://docs.pydantic.dev/latest/", None),
46+
"flask": ("https://flask.palletsprojects.com/en/stable/", None),
4647
}
4748

4849
# -- Options for HTML output ----------------------------------------------

doc/guides/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Documentation guides package."""

doc/guides/_examples/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Runnable examples used by the documentation."""
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import json
2+
from http import HTTPStatus
3+
4+
from django.http import HttpResponse
5+
from django.urls import path
6+
from django.urls import register_converter
7+
from django.utils.decorators import method_decorator
8+
from django.views import View
9+
from django.views.decorators.csrf import csrf_exempt
10+
from pydantic import ValidationError
11+
12+
from scim2_models import Context
13+
from scim2_models import Error
14+
from scim2_models import ListResponse
15+
from scim2_models import PatchOp
16+
from scim2_models import SearchRequest
17+
from scim2_models import UniquenessException
18+
from scim2_models import User
19+
20+
from .integrations import delete_record
21+
from .integrations import from_scim_user
22+
from .integrations import get_record
23+
from .integrations import list_records
24+
from .integrations import save_record
25+
from .integrations import to_scim_user
26+
27+
# -- setup-start --
28+
def scim_response(payload, status=HTTPStatus.OK):
29+
"""Build a Django response with the SCIM media type."""
30+
return HttpResponse(
31+
payload,
32+
status=status,
33+
content_type="application/scim+json",
34+
)
35+
# -- setup-end --
36+
37+
38+
# -- refinements-start --
39+
# -- converters-start --
40+
class UserConverter:
41+
regex = "[^/]+"
42+
43+
def to_python(self, id):
44+
try:
45+
return get_record(id)
46+
except KeyError:
47+
raise ValueError
48+
49+
def to_url(self, record):
50+
return record["id"]
51+
52+
53+
register_converter(UserConverter, "user")
54+
# -- converters-end --
55+
56+
57+
# -- validation-helper-start --
58+
def scim_validation_error(error):
59+
"""Turn Pydantic validation errors into a SCIM error response."""
60+
scim_error = Error.from_validation_error(error.errors()[0])
61+
return scim_response(scim_error.model_dump_json(), scim_error.status)
62+
# -- validation-helper-end --
63+
64+
65+
# -- uniqueness-helper-start --
66+
def scim_uniqueness_error(error):
67+
"""Turn uniqueness errors into a SCIM 409 response."""
68+
scim_error = UniquenessException(detail=str(error)).to_error()
69+
return scim_response(scim_error.model_dump_json(), HTTPStatus.CONFLICT)
70+
# -- uniqueness-helper-end --
71+
72+
73+
# -- error-handler-start --
74+
def handler404(request, exception):
75+
"""Turn Django 404 errors into SCIM error responses."""
76+
scim_error = Error(status=404, detail=str(exception))
77+
return scim_response(scim_error.model_dump_json(), HTTPStatus.NOT_FOUND)
78+
# -- error-handler-end --
79+
# -- refinements-end --
80+
81+
82+
# -- endpoints-start --
83+
# -- single-resource-start --
84+
@method_decorator(csrf_exempt, name="dispatch")
85+
class UserView(View):
86+
"""Handle GET, PATCH and DELETE on one SCIM user resource."""
87+
88+
def get(self, request, app_record):
89+
scim_user = to_scim_user(app_record)
90+
return scim_response(
91+
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
92+
)
93+
94+
def delete(self, request, app_record):
95+
delete_record(app_record["id"])
96+
return scim_response("", HTTPStatus.NO_CONTENT)
97+
98+
def patch(self, request, app_record):
99+
try:
100+
patch = PatchOp[User].model_validate(
101+
json.loads(request.body),
102+
scim_ctx=Context.RESOURCE_PATCH_REQUEST,
103+
)
104+
except ValidationError as error:
105+
return scim_validation_error(error)
106+
107+
scim_user = to_scim_user(app_record)
108+
patch.patch(scim_user)
109+
110+
updated_record = from_scim_user(scim_user)
111+
try:
112+
save_record(updated_record)
113+
except ValueError as error:
114+
return scim_uniqueness_error(error)
115+
116+
return scim_response(
117+
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE)
118+
)
119+
# -- single-resource-end --
120+
121+
122+
# -- collection-start --
123+
@method_decorator(csrf_exempt, name="dispatch")
124+
class UsersView(View):
125+
"""Handle GET and POST on the SCIM users collection."""
126+
127+
def get(self, request):
128+
try:
129+
req = SearchRequest.model_validate(request.GET)
130+
except ValidationError as error:
131+
return scim_validation_error(error)
132+
all_records = list_records()
133+
page = all_records[req.start_index_0 : req.stop_index_0]
134+
resources = [to_scim_user(record) for record in page]
135+
response = ListResponse[User](
136+
total_results=len(all_records),
137+
start_index=req.start_index or 1,
138+
items_per_page=len(resources),
139+
resources=resources,
140+
)
141+
return scim_response(
142+
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
143+
)
144+
145+
def post(self, request):
146+
try:
147+
request_user = User.model_validate(
148+
json.loads(request.body),
149+
scim_ctx=Context.RESOURCE_CREATION_REQUEST,
150+
)
151+
except ValidationError as error:
152+
return scim_validation_error(error)
153+
154+
app_record = from_scim_user(request_user)
155+
try:
156+
save_record(app_record)
157+
except ValueError as error:
158+
return scim_uniqueness_error(error)
159+
160+
response_user = to_scim_user(app_record)
161+
return scim_response(
162+
response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE),
163+
HTTPStatus.CREATED,
164+
)
165+
166+
167+
urlpatterns = [
168+
path("scim/v2/Users", UsersView.as_view(), name="scim_users"),
169+
path("scim/v2/Users/<user:app_record>", UserView.as_view(), name="scim_user"),
170+
]
171+
# -- collection-end --
172+
# -- endpoints-end --
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
from http import HTTPStatus
2+
3+
from flask import Blueprint
4+
from flask import request
5+
from pydantic import ValidationError
6+
from werkzeug.routing import BaseConverter
7+
from werkzeug.routing import ValidationError as RoutingValidationError
8+
9+
from scim2_models import Context
10+
from scim2_models import Error
11+
from scim2_models import ListResponse
12+
from scim2_models import PatchOp
13+
from scim2_models import SearchRequest
14+
from scim2_models import UniquenessException
15+
from scim2_models import User
16+
17+
from .integrations import delete_record
18+
from .integrations import from_scim_user
19+
from .integrations import get_record
20+
from .integrations import list_records
21+
from .integrations import save_record
22+
from .integrations import to_scim_user
23+
24+
# -- setup-start --
25+
bp = Blueprint("scim", __name__, url_prefix="/scim/v2")
26+
27+
28+
@bp.after_request
29+
def add_scim_content_type(response):
30+
"""Expose every endpoint with the SCIM media type."""
31+
response.headers["Content-Type"] = "application/scim+json"
32+
return response
33+
# -- setup-end --
34+
35+
36+
# -- refinements-start --
37+
# -- converters-start --
38+
class UserConverter(BaseConverter):
39+
"""Resolve a user identifier to an application record."""
40+
41+
def to_python(self, id):
42+
try:
43+
return get_record(id)
44+
except KeyError:
45+
raise RoutingValidationError()
46+
47+
def to_url(self, record):
48+
return record["id"]
49+
50+
51+
@bp.record_once
52+
def _register_converter(state):
53+
state.app.url_map.converters["user"] = UserConverter
54+
# -- converters-end --
55+
56+
57+
# -- error-handlers-start --
58+
@bp.errorhandler(ValidationError)
59+
def handle_validation_error(error):
60+
"""Turn Pydantic validation errors into SCIM error responses."""
61+
scim_error = Error.from_validation_error(error.errors()[0])
62+
return scim_error.model_dump_json(), scim_error.status
63+
64+
65+
@bp.errorhandler(404)
66+
def handle_not_found(error):
67+
"""Turn Flask 404 errors into SCIM error responses."""
68+
scim_error = Error(status=404, detail=str(error.description))
69+
return scim_error.model_dump_json(), HTTPStatus.NOT_FOUND
70+
71+
72+
@bp.errorhandler(ValueError)
73+
def handle_value_error(error):
74+
"""Turn uniqueness errors into SCIM 409 responses."""
75+
scim_error = UniquenessException(detail=str(error)).to_error()
76+
return scim_error.model_dump_json(), HTTPStatus.CONFLICT
77+
# -- error-handlers-end --
78+
# -- refinements-end --
79+
80+
81+
# -- endpoints-start --
82+
# -- single-resource-start --
83+
# -- get-user-start --
84+
@bp.get("/Users/<user:app_record>")
85+
def get_user(app_record):
86+
"""Return one SCIM user."""
87+
scim_user = to_scim_user(app_record)
88+
return (
89+
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
90+
HTTPStatus.OK,
91+
)
92+
# -- get-user-end --
93+
94+
95+
# -- patch-user-start --
96+
@bp.patch("/Users/<user:app_record>")
97+
def patch_user(app_record):
98+
"""Apply a SCIM PatchOp to an existing user."""
99+
scim_user = to_scim_user(app_record)
100+
patch = PatchOp[User].model_validate(
101+
request.get_json(),
102+
scim_ctx=Context.RESOURCE_PATCH_REQUEST,
103+
)
104+
patch.patch(scim_user)
105+
106+
updated_record = from_scim_user(scim_user)
107+
save_record(updated_record)
108+
109+
return (
110+
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE),
111+
HTTPStatus.OK,
112+
)
113+
# -- patch-user-end --
114+
115+
116+
# -- delete-user-start --
117+
@bp.delete("/Users/<user:app_record>")
118+
def delete_user(app_record):
119+
"""Delete an existing user."""
120+
delete_record(app_record["id"])
121+
return "", HTTPStatus.NO_CONTENT
122+
# -- delete-user-end --
123+
# -- single-resource-end --
124+
125+
126+
# -- collection-start --
127+
# -- list-users-start --
128+
@bp.get("/Users")
129+
def list_users():
130+
"""Return one page of users as a SCIM ListResponse."""
131+
req = SearchRequest.model_validate(request.args)
132+
all_records = list_records()
133+
page = all_records[req.start_index_0 : req.stop_index_0]
134+
resources = [to_scim_user(record) for record in page]
135+
response = ListResponse[User](
136+
total_results=len(all_records),
137+
start_index=req.start_index or 1,
138+
items_per_page=len(resources),
139+
resources=resources,
140+
)
141+
return (
142+
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
143+
HTTPStatus.OK,
144+
)
145+
# -- list-users-end --
146+
147+
148+
# -- create-user-start --
149+
@bp.post("/Users")
150+
def create_user():
151+
"""Validate a SCIM creation payload and store the new user."""
152+
request_user = User.model_validate(
153+
request.get_json(),
154+
scim_ctx=Context.RESOURCE_CREATION_REQUEST,
155+
)
156+
app_record = from_scim_user(request_user)
157+
save_record(app_record)
158+
159+
response_user = to_scim_user(app_record)
160+
return (
161+
response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE),
162+
HTTPStatus.CREATED,
163+
)
164+
# -- create-user-end --
165+
# -- collection-end --
166+
# -- endpoints-end --

0 commit comments

Comments
 (0)