Skip to content

Commit 34d2dd0

Browse files
committed
doc: FastAPI integration
1 parent 4d05422 commit 34d2dd0

File tree

6 files changed

+730
-0
lines changed

6 files changed

+730
-0
lines changed
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
from http import HTTPStatus
2+
3+
from fastapi import APIRouter
4+
from fastapi import Depends
5+
from fastapi import FastAPI
6+
from fastapi import HTTPException
7+
from fastapi import Request
8+
from fastapi import Response
9+
from pydantic import ValidationError
10+
11+
from scim2_models import Context
12+
from scim2_models import Error
13+
from scim2_models import ListResponse
14+
from scim2_models import PatchOp
15+
from scim2_models import ResourceType
16+
from scim2_models import ResponseParameters
17+
from scim2_models import Schema
18+
from scim2_models import SearchRequest
19+
from scim2_models import UniquenessException
20+
from scim2_models import User
21+
22+
from .integrations import PreconditionFailed
23+
from .integrations import check_etag
24+
from .integrations import delete_record
25+
from .integrations import from_scim_user
26+
from .integrations import get_record
27+
from .integrations import get_resource_type
28+
from .integrations import get_resource_types
29+
from .integrations import get_schema
30+
from .integrations import get_schemas
31+
from .integrations import list_records
32+
from .integrations import make_etag
33+
from .integrations import save_record
34+
from .integrations import service_provider_config
35+
from .integrations import to_scim_user
36+
37+
# -- setup-start --
38+
app = FastAPI()
39+
router = APIRouter(prefix="/scim/v2")
40+
41+
42+
@app.middleware("http")
43+
async def add_scim_content_type(request: Request, call_next):
44+
"""Set the SCIM media type on every response."""
45+
response = await call_next(request)
46+
response.headers["Content-Type"] = "application/scim+json"
47+
return response
48+
49+
50+
def resource_location(request, app_record):
51+
"""Return the canonical URL for a user record."""
52+
return str(request.url_for("get_user", user_id=app_record["id"]))
53+
# -- setup-end --
54+
55+
56+
# -- refinements-start --
57+
# -- dependency-start --
58+
def resolve_user(user_id: str):
59+
"""Resolve a user identifier to an application record."""
60+
try:
61+
return get_record(user_id)
62+
except KeyError:
63+
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
64+
# -- dependency-end --
65+
66+
67+
# -- error-handlers-start --
68+
@app.exception_handler(ValidationError)
69+
async def handle_validation_error(request, error):
70+
"""Turn Pydantic validation errors into SCIM error responses."""
71+
scim_error = Error.from_validation_error(error.errors()[0])
72+
return Response(scim_error.model_dump_json(), status_code=scim_error.status)
73+
74+
75+
@app.exception_handler(HTTPException)
76+
async def handle_http_exception(request, error):
77+
"""Turn HTTP exceptions into SCIM error responses."""
78+
scim_error = Error(status=error.status_code, detail=error.detail or "")
79+
return Response(scim_error.model_dump_json(), status_code=error.status_code)
80+
81+
82+
@app.exception_handler(ValueError)
83+
async def handle_value_error(request, error):
84+
"""Turn uniqueness errors into SCIM 409 responses."""
85+
scim_error = UniquenessException(detail=str(error)).to_error()
86+
return Response(scim_error.model_dump_json(), status_code=HTTPStatus.CONFLICT)
87+
88+
89+
@app.exception_handler(PreconditionFailed)
90+
async def handle_precondition_failed(request, error):
91+
"""Turn ETag mismatches into SCIM 412 responses."""
92+
scim_error = Error(status=412, detail="ETag mismatch")
93+
return Response(
94+
scim_error.model_dump_json(), status_code=HTTPStatus.PRECONDITION_FAILED
95+
)
96+
# -- error-handlers-end --
97+
# -- refinements-end --
98+
99+
100+
# -- endpoints-start --
101+
# -- single-resource-start --
102+
# -- get-user-start --
103+
@router.get("/Users/{user_id}")
104+
async def get_user(request: Request, app_record: dict = Depends(resolve_user)):
105+
"""Return one SCIM user."""
106+
req = ResponseParameters.model_validate(dict(request.query_params))
107+
scim_user = to_scim_user(app_record, resource_location(request, app_record))
108+
etag = make_etag(app_record)
109+
if_none_match = request.headers.get("If-None-Match")
110+
if if_none_match and etag in [t.strip() for t in if_none_match.split(",")]:
111+
return Response(status_code=HTTPStatus.NOT_MODIFIED)
112+
return Response(
113+
scim_user.model_dump_json(
114+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
115+
attributes=req.attributes,
116+
excluded_attributes=req.excluded_attributes,
117+
),
118+
headers={"ETag": etag},
119+
)
120+
# -- get-user-end --
121+
122+
123+
# -- patch-user-start --
124+
@router.patch("/Users/{user_id}")
125+
async def patch_user(request: Request, app_record: dict = Depends(resolve_user)):
126+
"""Apply a SCIM PatchOp to an existing user."""
127+
check_etag(app_record, request.headers.get("If-Match"))
128+
scim_user = to_scim_user(app_record, resource_location(request, app_record))
129+
patch = PatchOp[User].model_validate(
130+
await request.json(),
131+
scim_ctx=Context.RESOURCE_PATCH_REQUEST,
132+
)
133+
patch.patch(scim_user)
134+
135+
updated_record = from_scim_user(scim_user)
136+
save_record(updated_record)
137+
138+
return Response(
139+
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE),
140+
headers={"ETag": make_etag(updated_record)},
141+
)
142+
# -- patch-user-end --
143+
144+
145+
# -- put-user-start --
146+
@router.put("/Users/{user_id}")
147+
async def replace_user(request: Request, app_record: dict = Depends(resolve_user)):
148+
"""Replace an existing user with a full SCIM resource."""
149+
check_etag(app_record, request.headers.get("If-Match"))
150+
existing_user = to_scim_user(app_record, resource_location(request, app_record))
151+
replacement = User.model_validate(
152+
await request.json(),
153+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
154+
original=existing_user,
155+
)
156+
157+
replacement.id = existing_user.id
158+
updated_record = from_scim_user(replacement)
159+
save_record(updated_record)
160+
161+
response_user = to_scim_user(
162+
updated_record, resource_location(request, updated_record)
163+
)
164+
return Response(
165+
response_user.model_dump_json(
166+
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
167+
),
168+
headers={"ETag": make_etag(updated_record)},
169+
)
170+
# -- put-user-end --
171+
172+
173+
# -- delete-user-start --
174+
@router.delete("/Users/{user_id}")
175+
async def delete_user(request: Request, app_record: dict = Depends(resolve_user)):
176+
"""Delete an existing user."""
177+
check_etag(app_record, request.headers.get("If-Match"))
178+
delete_record(app_record["id"])
179+
return Response(status_code=HTTPStatus.NO_CONTENT)
180+
# -- delete-user-end --
181+
# -- single-resource-end --
182+
183+
184+
# -- collection-start --
185+
# -- list-users-start --
186+
@router.get("/Users")
187+
async def list_users(request: Request):
188+
"""Return one page of users as a SCIM ListResponse."""
189+
req = SearchRequest.model_validate(dict(request.query_params))
190+
total, page = list_records(req.start_index_0, req.stop_index_0)
191+
resources = [
192+
to_scim_user(record, resource_location(request, record)) for record in page
193+
]
194+
response = ListResponse[User](
195+
total_results=total,
196+
start_index=req.start_index or 1,
197+
items_per_page=len(resources),
198+
resources=resources,
199+
)
200+
return Response(
201+
response.model_dump_json(
202+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
203+
attributes=req.attributes,
204+
excluded_attributes=req.excluded_attributes,
205+
),
206+
)
207+
# -- list-users-end --
208+
209+
210+
# -- create-user-start --
211+
@router.post("/Users")
212+
async def create_user(request: Request):
213+
"""Validate a SCIM creation payload and store the new user."""
214+
request_user = User.model_validate(
215+
await request.json(),
216+
scim_ctx=Context.RESOURCE_CREATION_REQUEST,
217+
)
218+
app_record = from_scim_user(request_user)
219+
save_record(app_record)
220+
221+
response_user = to_scim_user(app_record, resource_location(request, app_record))
222+
return Response(
223+
response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE),
224+
status_code=HTTPStatus.CREATED,
225+
headers={"ETag": make_etag(app_record)},
226+
)
227+
# -- create-user-end --
228+
# -- collection-end --
229+
230+
231+
# -- discovery-start --
232+
# -- schemas-start --
233+
@router.get("/Schemas")
234+
async def list_schemas(request: Request):
235+
"""Return one page of SCIM schemas the server exposes."""
236+
req = SearchRequest.model_validate(dict(request.query_params))
237+
total, page = get_schemas(req.start_index_0, req.stop_index_0)
238+
response = ListResponse[Schema](
239+
total_results=total,
240+
start_index=req.start_index or 1,
241+
items_per_page=len(page),
242+
resources=page,
243+
)
244+
return Response(
245+
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
246+
)
247+
248+
249+
@router.get("/Schemas/{schema_id:path}")
250+
async def get_schema_by_id(schema_id: str):
251+
"""Return one SCIM schema by its URI identifier."""
252+
try:
253+
schema = get_schema(schema_id)
254+
except KeyError:
255+
scim_error = Error(status=404, detail=f"Schema {schema_id!r} not found")
256+
return Response(scim_error.model_dump_json(), status_code=HTTPStatus.NOT_FOUND)
257+
return Response(
258+
schema.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
259+
)
260+
# -- schemas-end --
261+
262+
263+
# -- resource-types-start --
264+
@router.get("/ResourceTypes")
265+
async def list_resource_types(request: Request):
266+
"""Return one page of SCIM resource types the server exposes."""
267+
req = SearchRequest.model_validate(dict(request.query_params))
268+
total, page = get_resource_types(req.start_index_0, req.stop_index_0)
269+
response = ListResponse[ResourceType](
270+
total_results=total,
271+
start_index=req.start_index or 1,
272+
items_per_page=len(page),
273+
resources=page,
274+
)
275+
return Response(
276+
response.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
277+
)
278+
279+
280+
@router.get("/ResourceTypes/{resource_type_id}")
281+
async def get_resource_type_by_id(resource_type_id: str):
282+
"""Return one SCIM resource type by its identifier."""
283+
try:
284+
rt = get_resource_type(resource_type_id)
285+
except KeyError:
286+
scim_error = Error(
287+
status=404, detail=f"ResourceType {resource_type_id!r} not found"
288+
)
289+
return Response(scim_error.model_dump_json(), status_code=HTTPStatus.NOT_FOUND)
290+
return Response(
291+
rt.model_dump_json(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
292+
)
293+
# -- resource-types-end --
294+
295+
296+
# -- service-provider-config-start --
297+
@router.get("/ServiceProviderConfig")
298+
async def get_service_provider_config():
299+
"""Return the SCIM service provider configuration."""
300+
return Response(
301+
service_provider_config.model_dump_json(
302+
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
303+
),
304+
)
305+
# -- service-provider-config-end --
306+
# -- discovery-end --
307+
308+
app.include_router(router)
309+
# -- endpoints-end --

0 commit comments

Comments
 (0)