POST /api/v1/user
- operationId
createUser- Idempotency
- Required
Idempotency-Key. Printable ASCII only; length 1–128. - Success
201 Created. Body: created user (includesclient_uuid).
Single entry point for the User HTTP API: product and ownership context, data contract shorthand, links to per-method pages, and the full internal narrative (runtime path, idempotency, errors, logging, metrics). Authoritative HTTP contract for integrators: OpenAPI.
A User represents a learner identity in Study App: keyed by
(system_user_id, system_uuid) for partner integrations, with profile attributes (display
name, locale, notification preferences, invalidation metadata). Creating and updating users is on the
critical path for onboarding; reads power dashboards and entitlement checks.
The Product API squad owns domain invariants (e.g. uniqueness within a tenant, soft-delete semantics); the Platform squad owns transport concerns (authn/authz at the edge, rate limits, observability baselines). This split is reflected in error codes and logs, not in duplicated prose on every method page.
Full JSON Schemas live in OpenAPI; this table is the engineering shorthand for reviews and runbooks.
| Field / group | Role | Notes |
|---|---|---|
system_user_id, system_uuid |
Composite key | Path parameters for instance-scoped reads and writes. |
| Profile block | Mutable projection | PUT/PATCH semantics documented per method; optimistic concurrency where applicable. |
| Audit / invalidation | System metadata | Not always exposed in external integrator docs; internal spec describes logging and metrics hooks. |
Navigation is keyed by HTTP method + path (how clients call the API). OpenAPI
operationId values are listed for cross-reference with code and the baseline JSON.
Use the left sidebar under API → User, or open a page from this table. Each method page links back to anchors on this document for idempotency, metrics, and the matching operation block.
| Method & path | operationId (ref.) |
Page |
|---|---|---|
POST /api/v1/user |
createUser |
Internal doc |
GET
/api/v1/user/{system_uuid}/{system_user_id}
|
getUserBySystemUserId |
Internal doc |
PUT
/api/v1/user/{system_uuid}/{system_user_id}
|
updateUserBySystemUserId |
Internal doc |
PATCH
/api/v1/user/{system_uuid}/{system_user_id}
|
patchUserBySystemUserId |
Internal doc |
The User feature exposes four operations under the versioned prefix /api/v1: create a user, fetch a
user by composite key, full update (PUT), and partial update (PATCH). All traffic is
served by the FastAPI app in app/main.py; handlers live in
app/api/v1/user.py, domain logic in app/services/user_service.py, and persistence in
app/repositories/user_repository.py backed by PostgreSQL.
There is no Kafka topic, message consumer, or third-party identity SaaS in this code path: integration is synchronous HTTP plus the application database only.
Who changes this feature: any contributor following
CONTRIBUTING.md — pull requests on main, review, and green CI.
There is no separate product ticket system encoded in the repo; git history and PR descriptions are the record
of who changed behavior.
make verify-ci before push (lint, types, OpenAPI,
contract tests, tests, docs-check) per CONTRIBUTING.
make openapi-accept-changes) so docs/openapi/openapi-baseline.json
matches runtime (ADR 0007).
CHANGELOG.md
(ADR 0013).
Base path: /api/v1/user (constant USER_HTTP_BASE_PATH in
app/api/v1/user.py). Protected routes require X-API-Key and are subject to rate limiting
per ADR 0005 (enforced in middleware, not in the handler
body).
POST /api/v1/usercreateUserIdempotency-Key. Printable ASCII only; length 1–128.201 Created. Body: created user (includes client_uuid).GET
/api/v1/user/{system_uuid}/{system_user_id}getUserBySystemUserId200 OK.PUT
/api/v1/user/{system_uuid}/{system_user_id}updateUserBySystemUserIdIdempotency-Key (same rules as POST).200 OK.PATCH
/api/v1/user/{system_uuid}/{system_user_id}patchUserBySystemUserIdIdempotency-Key (same rules as POST).200 OK.
Write operations hash the canonical JSON body (create: full dump; update: full dump; patch: only set fields via
exclude_unset=True) for idempotency comparison — see Idempotency.
A typical request passes through the middleware stack in app/main.py (order of registration:
metrics/latency wrapper, body size limit, API key auth + in-memory rate limit, security headers, request ID
context), then the route handler, then:
app/api/v1/user.py) — parsing, idempotency read/replay or delegate,
OpenAPI-declared responses.app/services/user_service.py) — business rules, duplicate detection,
HTTPException for domain outcomes.app/repositories/user_repository.py) — SQLAlchemy
SELECT / commit+refresh for users table.app/repositories/idempotency_repository.py) — read/write
idempotency_keys rows for successful writes only.(system_user_id, system_uuid) — unique in DB
(uq_users_system_user_id_system_uuid on users).
client_uuid (string UUID) — generated server-side on create.
username, full_name,
timezone, invalidation flags, optional system_uuid change on update/patch per schema).
Validation of shapes and timezones is at the API schema layer (app/schemas/user.py) before service
calls.
Writes require Idempotency-Key. The handler computes a SHA-256 hash of the JSON payload (sorted keys
in storage) and looks up (endpoint_path, idempotency_key):
endpoint_path = "/api/v1/user".
endpoint_path = "/api/v1/user/{system_uuid}/{system_user_id}" (literal
path with UUID string).
endpoint_path = "PATCH /api/v1/user/{system_uuid}/{system_user_id}" — note
the PATCH prefix so PUT and PATCH on the same URL do not share idempotency records.
If a record exists and the hash matches, the stored response body is replayed (same status code as first
success). Hash mismatch → 409 with COMMON_409 /
IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD. Missing header → 400
COMMON_400 / IDEMPOTENCY_KEY_REQUIRED.
Structured business errors use the shared envelope (code, key, message,
source) per ADR 0003. FastAPI returns them
as JSON detail.
| HTTP | Code / key (representative) | When |
|---|---|---|
400 |
USER_101 / USER_CREATE_ALREADY_EXISTS |
Create: duplicate (system_user_id, system_uuid) — logged at warning in
UserService.create. |
400 |
USER_102 / USER_PATCH_BODY_EMPTY |
Patch: no fields present after unset stripping — warning in
UserService.patch. |
400 |
COMMON_400 / IDEMPOTENCY_KEY_REQUIRED |
Write without Idempotency-Key. |
404 |
USER_404 / USER_NOT_FOUND |
Get / update / patch: no row for composite key — warning in
UserService.get_or_404. |
409 |
COMMON_409 / IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD |
Same idempotency key, different body hash. |
422 |
Validation (error_type / field errors) |
Pydantic request validation — global handler logs
validation_error method=… path=… at warning
(app/main.py). |
413 |
COMMON_413 / body too large |
Middleware rejects oversized body before the handler runs — warning
request_body_limit_exceeded. |
500 |
— | Uncaught exceptions: ASGI middleware logs request_failed with exception stack
(logger.exception). |
Auth failures (401) and rate limits (429) are produced by security middleware before
route execution; see ADR 0005 and app/core/security.py.
Logging is configured in app/core/logging.py: plain text or NDJSON with
request_id from X-Request-Id (set in request_context_middleware) per
ADR 0023.
Every completed request emits an info line from the outer HTTP middleware:
request_done method=… path=… status=… elapsed_ms=… (path is the request URL path; successful User
calls include status=200 or 201 as appropriate).
User router (app.api.v1.user) additionally logs:
create_user_idempotent_replay, update_user_idempotent_replay,
patch_user_idempotent_replay — info, idempotency key only.create_user_requested, update_user_requested, patch_user_requested —
info, system_user_id and system_uuid.create_user_succeeded — info, system_user_id, client_uuid
after persist.
UserService logs create_user_duplicate, get_user_not_found,
patch_user_empty_body at warning before raising; create_user_persisted at
info after insert.
Prometheus metrics (app/core/metrics.py) record:
http_requests_total and http_request_duration_seconds with labels including
path_template from the FastAPI route template (e.g. /api/v1/user/{system_uuid}/{system_user_id}),
method, and status.
db_operation_duration_seconds for SQLAlchemy operations (read/write classification from statement
text).
trace_id / span_id in JSON logs are reserved for future OpenTelemetry; correlation today
is primarily request_id.
| Dependency | Role for User API |
|---|---|
| PostgreSQL | Authoritative storage for users and idempotency_keys (via SQLAlchemy). |
| In-memory rate limiter | Per-client sliding window on protected paths (app/core/security.py); not a separate network
service. |
| Configured API keys | Validated in middleware against settings — no call to an external IdP in this flow. |
app/api/v1/user.pyapp/services/user_service.pyapp/repositories/user_repository.py,
app/repositories/idempotency_repository.pyapp/models/core/user.py (users table)tests/api/v1/test_user_create.py, helpers in
tests/api/v1/user_test_utils.py| Date | Change | Author |
|---|---|---|
| Renamed section to Page history; added Author column (style guide alignment). | Ivan Boyarkin | |
| Initial internal capability doc: operations, layers, idempotency keys, logging, metrics, dependencies. | — | |
File moved from docs/internal/user-http-api.html to docs/internal/api/user/user-http-api.html (resource-scoped layout). |
— | |
Unified long-form content merged into docs/internal/api/user/index.html; user-http-api.html retained as redirect stub for bookmarks. |
— |