# Public API Design **Status:** Stable draft for MVP implementation **Related:** [02-data-model.md](02-data-model.md), [05-rbac-permissions.md](05-rbac-permissions.md), ADR-008 --- ## 1. Principles 1. **One API.** The frontend consumes the same versioned public API (dogfooding); there is no privileged private channel except auth/session bootstrap. This keeps the public API complete and honest. 2. **REST + JSON**, resource-oriented, with a published **OpenAPI 3.1 spec** generated from code (drf-spectacular) and CI-checked against committed snapshots so breaking changes are explicit diffs in PRs. 3. **Stable contracts:** URL-versioned (`/api/v1/`); additive changes only within a major; deprecations announced ≥ 6 months with `Deprecation` + `Sunset` headers. 4. **Open data bias:** published content is readable without authentication, generously rate-limited, with full OER export — the API is part of the open-education mission, not an afterthought. ## 2. Conventions - **IDs:** prefixed opaque IDs (`prb_`, `pv_`, `crs_`, `usr_`, `rev_`…) — type-tagged, non-enumerable (ULID-backed). Slugs are addressable aliases for content reads. - **Envelopes:** ```json // success (collection) { "data": [ … ], "meta": { "cursor": { "next": "eyJ…" }, "totalEstimate": 1432 } } // error (RFC 9457 problem+json) { "type": "https://docs.fablepool.example/errors/validation", "title": "Validation failed", "status": 422, "code": "validation_failed", "requestId": "req_…", "errors": [ { "path": "/answerSpec/tolerance", "code": "required", "message": "…" } ] } ``` - **Pagination:** cursor-based everywhere (`?cursor=&limit=`, max 100). No offset pagination (breaks under concurrent writes, hurts the DB). - **Sparse fields & expansion:** `?fields=id,title,difficulty` and `?expand=author,tags` (one level, allowlisted per endpoint) — central to the low-bandwidth strategy. - **Caching:** published-content responses send `ETag: ""` + `Cache-Control: public, max-age=60, stale-while-revalidate=600`; conditional `If-None-Match` honored. - **Idempotency:** all unsafe POSTs accept `Idempotency-Key` (stored 24 h); attempt submission requires it (mobile retries must not double-submit). - **Timestamps** RFC 3339 UTC; **i18n** via `Accept-Language` for localized metadata, explicit `locale` query param for content selection. ## 3. Authentication & authorization | Mechanism | Use | |---|---| | Session cookie (HttpOnly, SameSite=Lax) + CSRF token | First-party web app | | **Personal access tokens** (`Authorization: Bearer fp_pat_…`, scoped, expiring, hashed at rest) | Scripts, integrations | | OAuth 2.1 authorization-code + PKCE (post-MVP) | Third-party apps | Token scopes mirror the policy layer: `read:content` (default, also anonymous), `write:content`, `read:progress`, `write:attempts`, `read:profile`, `moderation` (grantable only to moderator accounts). Every token action passes the same policy checks as the owning user — tokens never exceed the user. **Rate limits** (Redis sliding window, per token/user/IP): anonymous 60/min; authenticated 300/min; attempt submissions 60/min; search 30/min; export 5/hour. Responses carry `RateLimit-*` headers (IETF draft format); 429 includes `Retry-After`. ## 4. Resource map (v1) ### Content ``` GET /api/v1/problems # search/filter: ?topic=&tags=&difficulty=&type=&author=&status=&q=&locale= POST /api/v1/problems # create draft entity GET /api/v1/problems/{id|slug} # published version by default; ?version=n GET /api/v1/problems/{id}/versions # version history + changelogs GET /api/v1/problems/{id}/versions/{n} PATCH /api/v1/problems/{id}/draft # update working draft (autosave) POST /api/v1/problems/{id}/submit # draft → submitted POST /api/v1/problems/{id}/withdraw POST /api/v1/problems/{id}/fork POST /api/v1/problems/{id}/rollback # {targetVersion, reason} DELETE /api/v1/problems/{id} # draft-only / admin rules (doc 05) GET /api/v1/problems/{id}/forks # public lineage ``` Courses mirror problems (`/api/v1/courses…`) plus: ``` GET /api/v1/courses/{id}/outline # modules → lessons tree (sparse) GET /api/v1/courses/{id}/export?version=n # OER bundle (zip), async for large: 202 + job link POST /api/v1/imports # multipart bundle upload → creates draft entities; 202 + job GET /api/v1/jobs/{id} # async job status (export, import, similarity) ``` ### Attempts & learning ``` POST /api/v1/problems/{id}/attempts # {versionId, answer, idempotencyKey} → graded result GET /api/v1/problems/{id}/attempts # own history GET /api/v1/problems/{id}/hints/{n}/reveal # POST-like reveal recorded? → POST /hints/{n}/reveal (records usage) POST /api/v1/enrollments # {courseId} GET /api/v1/me/progress?course=… # per-lesson completion GET /api/v1/me/review-queue # spaced-repetition due items POST /api/v1/me/review-queue/{itemId}/result # recall grade → scheduler update GET /api/v1/me/streak GET/POST/DELETE /api/v1/me/bookmarks GET /api/v1/me/recommendations ``` Attempt grading is synchronous for MCQ/numeric/symbolic/ordering (target p95 < 300 ms), asynchronous for code challenges (`202` + `attemptId`, poll or SSE `GET /attempts/{id}/events`). **Answer specs and canonical solutions are never present in any read payload before a correct attempt or explicit reveal**, enforced by serializer-level field policies with tests. ### Review & community ``` GET /api/v1/reviews/queue # reviewer-scoped POST /api/v1/reviews/{id}/claim | /unclaim POST /api/v1/reviews/{id}/decision # {outcome, checklist, rationale} GET/POST /api/v1/reviews/{id}/comments GET/POST /api/v1/threads?target=prb_… # discussions GET/POST /api/v1/threads/{id}/comments POST /api/v1/votes # {targetType, targetId, value: 1|-1|0} POST /api/v1/reports # {targetType, targetId, reasonClass, detail} ``` ### Metadata, users, search ``` GET /api/v1/tags | /topics # topic tree GET /api/v1/users/{username} # public profile, published content, reputation GET /api/v1/me | PATCH /api/v1/me GET /api/v1/me/notifications | POST …/{id}/read GET /api/v1/search?q=&type=problem,course&… # Meilisearch-backed, typo-tolerant, faceted (facets in meta) GET /api/v1/me/export # GDPR data export (async job) ``` ### Moderation/admin (scope-gated, same API) ``` GET /api/v1/moderation/reports?status=open POST /api/v1/moderation/reports/{id}/resolve POST /api/v1/moderation/content/{id}/retract # {reasonClass, note} POST /api/v1/moderation/users/{id}/suspend GET /api/v1/admin/audit-events?… # filtered per doc 08 §6 ``` ## 5. Webhooks (MVP-lite) Instance-level outgoing webhooks (admin-configured; per-user post-MVP): `content.published`, `review.decided`, `report.created`. Deliveries are signed (`X-Fablepool-Signature: sha256=…` HMAC over body + timestamp), retried with exponential backoff for 24 h, and visible in an admin delivery log. ## 6. Error code registry (excerpt) | HTTP | code | When | |---|---|---| | 400 | `malformed_request` | unparsable body/params | | 401 | `auth_required` | no/expired credentials | | 403 | `` e.g. `not_maintainer`, `coi_violation` | policy denial (codes from doc 05 §5.1) | | 404 | `not_found` | also used instead of 403 where existence itself is private (others' drafts) | | 409 | `version_conflict` | draft autosave with stale `baseHash` (optimistic concurrency); `no_changes` on identical resubmit | | 410 | `retracted` | retracted content permalinks | | 422 | `validation_failed` | schema-valid JSON, invalid semantics | | 429 | `rate_limited` | with `Retry-After` | Draft updates use optimistic concurrency: `PATCH …/draft` requires the `baseHash` of the document the client edited; mismatch returns 409 with the current document so editors can merge — protecting co-maintainers from silent overwrites. ## 7. SDK & docs plan - OpenAPI spec published at `/api/v1/openapi.json`; Redoc-rendered reference at `docs.fablepool.example/api`. - A thin generated TypeScript client (`@fablepool/api-client`, openapi-typescript + fetch wrapper) is what the frontend itself uses — guaranteeing the spec stays truthful. - Stability tiers in the spec via `x-stability: stable|beta` per operation; everything moderation/admin starts `beta`.