Skip to content

API Catalog

Francisca Portugal edited this page Oct 27, 2026 · 7 revisions

The index of the HTTP API: base URL, auth model, error contract, conventions, and every live route in one table. Status: current. Only routes marked ✅ exist; shapes live on the per-resource pages.

The backend is an Elysia app running on Bun.

Base URL

Environment Base URL Notes
Production https://tts.niaefeup.pt/api/v1 Niployments mounts the service under /api
Local http://localhost:3000/v1 PUBLIC_API_PREFIX is empty outside production

The /api segment is added by the deployment, not declared in the code. Routes are declared under /v1 in apps/backend/src/interface/routes/, so a route written as /v1/courses is /api/v1/courses in production and /v1/courses locally.

GET / and /health/ are the only paths outside /v1. The legacy compatibility routes are mounted at the root as well (see Auth API).

The frontend resolves the same base in apps/frontend/src/api/client.ts: VITE_APP_BACKEND_URL if set, otherwise http://localhost:3000 in dev and ${window.location.origin}/api in a production build.

Authentication

Auth is a session cookie, never a JWT or bearer token. The full flow, cookie attributes and response shapes are in Auth API.

  1. GET /v1/auth/login redirects to SIGARRA's OIDC provider.
  2. SIGARRA redirects back to GET /v1/auth/callback, which sets an HttpOnly, SameSite=Lax, Secure (per config) session cookie and redirects to the frontend.
  3. Every later request sends that cookie. POST /v1/auth/logout deletes both the server-side session and the cookie.
Requirement Routes
Session required — anonymous callers get 401 unauthorized before the handler runs /v1/faculties/*, /v1/courses/*, /v1/course-units/*
Session optional — answers { signed: false } when anonymous /v1/auth/me, /v1/auth/logout, /auth/info/, /oidc-auth/logout/
Public /, /health/, /v1/auth/login, /v1/auth/callback, /v1/auth/dev-login (dev only), /oidc-auth/authenticate/, /sigarra_login/ (dev only)

There are no role gates on any implemented route. isAdmin and role are exposed on the session, but admin-only endpoints do not exist yet.

Dev login is enabled by DEV_LOGIN_ENABLED and is forced off in production. When disabled, POST /v1/auth/dev-login and POST /sigarra_login/ answer 404 not-found instead of 403.

Error contract

Every failure — thrown, validated, unmatched or unexpected — is normalized by the global onError hook to the same body:

{ "error": "Faculty FEUP was not found", "code": "faculty-not-found" }
  • code is stable and machine-readable; branch on it.
  • error is a human-readable message, safe to show.
  • Every 5xx body is exactly { "error": "Something went wrong", "code": "<code>" } — the cause is logged, never returned.
Status code When
400 route-specific (oidc-missing-code, …) The route decided the input is semantically wrong
401 unauthorized, invalid-credentials, oidc-auth-error No session, or login failed
403 forbidden Reserved; no implemented route uses it
404 not-found, faculty-not-found, course-not-found, course-unit-not-found No route, or a well-formed id/acronym that does not exist
409 conflict Reserved for the exchange lifecycle
422 validation-error Body/query/path did not match the Elysia schema
500 internal-error Bug, or a route failed its own response schema
502 oidc-callback-failed SIGARRA token exchange failed
503 oidc-not-configured, dev-login-not-configured Required configuration is missing

400 vs 422: a schema mismatch (e.g. ?year=abc, /:id/not-a-number, malformed JSON) is 422. A 400 is only used when the route checks the value itself and rejects it.

Conventions

  • JSON in, JSON out. No form encoding, no XML.
  • Response schemas are enforced. Every route declares a response schema; Elysia strips fields the schema does not list. A response that fails its own schema becomes 500 internal-error.
  • Pagination is not implemented. No list endpoint accepts page/limit today.
  • Versioning is the /v1 path segment. There is no Accept-Version header.
  • Localization is not implemented; every message is English.
  • Trailing slashes follow the declaration: catalog routes have none (/v1/courses), /health/ and the legacy compatibility routes keep theirs.
  • OpenAPI is dev-only. With NODE_ENV !== 'production', Scalar is served at /openapi and the document at /openapi/json.

Live routes

19 routes are registered. Each group has its own page with shapes, examples and per-route errors.

Service

Status Method Path Auth Description
✅ GET / public Liveness greeting: { "message": "Hello from tts-be!" }
✅ GET /health/ public Health check: { "status": "ok" }

Auth — Auth API

Status Method Path Auth Description
✅ GET /v1/auth/login public Begin OIDC login; 302 to SIGARRA
✅ GET /v1/auth/callback public OIDC return; sets the session cookie, 302 to the frontend
✅ GET /v1/auth/me optional Current user or { "signed": false }
✅ POST /v1/auth/dev-login public (dev only) Username/password login for local development
✅ POST /v1/auth/logout optional Deletes the session and clears the cookie
🔁 GET /oidc-auth/authenticate/ public Legacy alias → /api/v1/auth/login; 302
🔁 POST /oidc-auth/logout/ optional Legacy logout
🔁 GET /auth/info/ optional Legacy session shape (username, eligible_exchange, is_admin)
🔁 POST /sigarra_login/ public (dev only) Legacy dev login

Catalog

Status Method Path Auth Description
✅ GET /v1/faculties session All faculties, ordered by acronym
✅ GET /v1/faculties/:acronym session One faculty
✅ GET /v1/faculties/:acronym/courses session Courses offered by a faculty; optional ?year=
✅ GET /v1/courses session All courses; optional ?year= and ?faculty=
✅ GET /v1/courses/:id session One course
✅ GET /v1/courses/:id/units session Course units of a course
✅ GET /v1/course-units/:id session One abstract course unit
✅ GET /v1/course-units/:id/occurrences session Every occurrence of a unit, newest year first

Per-resource pages: Faculties API · Courses API · Course units API.

Designed, not implemented

These groups come from the API design document and are not registered. They are listed here only so nobody mistakes the design for a contract.

Status Group Examples
🚧 Occurrences and classes GET /courses/:id/occurrences, GET /occurrences/:id/classes, GET /occurrences/hashes
🚧 Classes and schedule slots Classes, lesson types, slots, teacher assignment
🚧 Student / user data Profile, current schedule, enrolled units, eligibility
🚧 Planner state GET/PUT /users/me/planner-state/:year — cross-device sync
🚧 Exchanges Create, detail, message, claim, accept, delete, lifecycle
🚧 Enrollment requests Student create, admin list and decide
🚧 Admin operations Scoped courses/occurrences, exchange queue, statistics
🚧 Period management POST /occurrences/:id/periods, POST /courses/:id/periods
🚧 Real-time collaboration Socket.IO handshake and planner events
🗑 CSV export (server-side) Dropped; the frontend exports CSV in the browser

The full list counts 69 core endpoints + 3 compatibility routes = 72 designed, of which 9 auth routes, 8 catalog routes and 2 service routes are live.

Note: endpoints.md lists /courses/:id/occurrences and /courses/:id/periods. Neither is implemented. Occurrences are only reachable today through /v1/course-units/:id/occurrences.

Adding or changing a route

Anything that changes the wire contract must update, in the same pull request:

  1. the route and its schemas in apps/backend/src/interface/routes/;
  2. registration in api.routes.ts and the auth middleware it mounts;
  3. a detail tag/summary for OpenAPI;
  4. this index and the resource page;
  5. a route test under apps/backend/tests/interface/routes/ using the shared fakes;
  6. the frontend Eden service, if the web app consumes it.

The step-by-step runbook is Adding an endpoint. The drift between this catalog and /openapi/json is meant to be caught automatically — see #930.

See also


Last verified: 2026-10-27

Clone this wiki locally