Skip to main content

API Contract

This page is a reference document. It covers the backend-wide API contract that sits above individual modules: how the OpenAPI spec is produced, which response envelopes are stable, how replay-safe POSTs work, which long-job routes share the same 202 Accepted contract, and which naming exceptions are intentional.

Source Paths

PathRole
lumie-backend/AGENTS.mdBackend-wide API rules such as Instant in DTOs, PageResponse<T>, and BusinessException usage
lumie-backend/app/build.gradle.ktsspringdoc-openapi dependency and snapshotOpenApi task
lumie-backend/app/src/main/java/com/lumie/app/config/OpenApiConfig.javaBase OpenAPI config, bearer auth scheme, and LocalTime string mapping
lumie-backend/app/src/main/java/com/lumie/app/config/OpenApiResponseRequiredCustomizer.javaMarks response-only schema properties as required for codegen accuracy
lumie-backend/app/src/main/java/com/lumie/app/config/OpenApiPageableFlattenCustomizer.javaFlattens Pageable to page, size, and sort query params
lumie-backend/app/src/test/java/com/lumie/app/OpenApiSnapshotTest.javaBackend-side OpenAPI drift gate
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/BusinessException.javaBase business failure type
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/GlobalExceptionHandler.javaGlobal ProblemDetail mapper
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/QuotaExceededException.javaExample typed exception with extra response properties
lumie-backend/libs/common/src/main/java/com/lumie/common/api/PageResponse.javaRequired list-response envelope
lumie-backend/libs/common/src/main/java/com/lumie/common/api/JobAcceptedResponse.javaShared long-job acceptance body
lumie-backend/libs/common/src/main/java/com/lumie/common/idempotency/IdempotencyHeaderInterceptor.javaHeader enforcement policy
lumie-backend/libs/common/src/main/java/com/lumie/common/idempotency/IdempotencyService.javaReplay, fingerprint, and cached-response semantics
lumie-backend/modules/student/src/main/java/com/lumie/student/adapter/in/web/StudentController.javaInline bulk import, 207 Multi-Status, and file-hash idempotency examples
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/web/{ResultController,ReportController,JoossamengAiReportController}.javaLong-job acceptance contract and optional idempotency examples
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/web/{ExamController,InternalOmrController}.java/full projection examples
lumie-backend/modules/content/src/main/java/com/lumie/content/adapter/in/web/QnaController.java/v1/qna naming exception
.codex/knowledge/DOMAIN_GLOSSARY.mdCanonical explanation for the /v1/qna acronym-path exception
lumie-frontend/orval.config.tsConsumer-side codegen target layout for the committed backend spec

OpenAPI Handoff And Codegen

The backend owns the contract source of truth. The live spec comes from /v3/api-docs, not from hand-written frontend types.

Three customizations make that spec codegen-safe:

  • OpenApiConfig declares the shared bearer-auth scheme and rewrites java.time.LocalTime to a string schema with an 09:00 example, so frontend codegen sees time-of-day fields as strings instead of Java object shapes.
  • OpenApiResponseRequiredCustomizer marks response-only DTO properties as required, which keeps generated TypeScript types as field: T rather than field?: T when Java records always emit the key.
  • OpenApiPageableFlattenCustomizer replaces nested Pageable params with flat page, size, and sort query params without breaking multipart upload routes.

The handoff to the frontend is explicit:

  • OpenApiSnapshotTest compares the live spec to the committed backend golden at app/src/test/resources/openapi/api-docs.json.
  • ./gradlew snapshotOpenApi exports the running backend's spec into lumie-frontend/openapi.json.
  • lumie-frontend/orval.config.ts then generates per-entity clients into src/entities/*/api/generated.ts.

Error Envelope: ProblemDetail And BusinessException

Backend rules require request-handling failures to flow through BusinessException or a typed subclass. Raw RuntimeException is reserved for startup or infrastructure fail-fast paths outside the normal HTTP contract.

GlobalExceptionHandler maps those failures to Content-Type: application/problem+json and keeps the response shape stable:

private ProblemDetail buildProblemDetail(BusinessException ex, HttpServletRequest request) {
ErrorCode errorCode = ex.getErrorCode();
ProblemDetail body = ProblemDetail.forStatusAndDetail(
HttpStatusCode.valueOf(errorCode.getStatus()),
ex.getMessage());
body.setType(URI.create(PROBLEM_TYPE_PREFIX + toKebab(errorCode.getCode())));
body.setTitle(errorCode.getMessage());
decorate(body, errorCode.getCode(), request);
return body;
}

Stable fields and extensions:

  • Standard RFC 7807 fields: type, title, status, detail, instance
  • Lumie extensions: code, timestamp, and tenantSlug when present in MDC
  • Typed exception properties from ExtensionProperties, such as QuotaExceededException adding metricType, currentUsage, and limit
  • Validation failures add violations[] with field, message, and rejectedValue

Representative error shape, anchored to GlobalExceptionHandler and QuotaExceededException:

{
"type": "urn:lumie:error:student-001",
"title": "Quota exceeded",
"status": 409,
"detail": "Quota exceeded for tenant 'acme'. STUDENTS: 201/200",
"instance": "/v1/students",
"code": "STUDENT_001",
"timestamp": "2026-06-14T09:00:00Z",
"tenantSlug": "acme",
"metricType": "STUDENTS",
"currentUsage": 201,
"limit": 200
}

Status-code policy is not hard-coded in controllers:

  • business exceptions use ErrorCode.status
  • unique conflicts and optimistic locking map to 409 Conflict
  • business-rule violations can map to 422 Unprocessable Entity
  • validation failures map to 400 Bad Request
  • unexpected failures map to 500 Internal Server Error

Pagination And Shared Response Shapes

Controllers must not leak Spring Data Page<T> directly. The backend rule and the live controllers both require PageResponse<T>:

public record PageResponse<T>(
List<T> items,
int page,
int perPage,
long total,
int totalPages,
boolean hasNext
) {}

That wrapper is the stable public list contract used by student, class, exam, content, notification, and other pageable controllers.

Representative list shape, anchored to PageResponse, StudentController, and QnaController:

{
"items": [],
"page": 0,
"perPage": 20,
"total": 0,
"totalPages": 0,
"hasNext": false
}

Request-side pagination shape is equally deliberate:

  • callers send page, size, and repeated sort params
  • callers do not send a nested pageable object
  • pageable endpoints should also declare @AllowedSortFields(...)

Idempotency Policy

The idempotency layer has two parts.

HTTP-boundary enforcement:

  • IdempotencyHeaderInterceptor enforces Idempotency-Key on mutating billing routes plus POST /v1/students/bulk
  • valid keys are 16 to 128 characters matching [A-Za-z0-9._:-]
  • exempt webhook paths stay outside this rule because they follow provider retry semantics, not client replay semantics

Controller and persistence semantics:

  • controllers still call IdempotencyService.executeOnce(...) or executeOnceWithoutResponseBody(...) explicitly
  • scope is (tenantSlug, endpointPath, idempotencyKey)
  • same key plus same canonical fingerprint replays the cached status and body
  • same key plus different fingerprint raises IdempotencyConflictException and returns 409 Conflict
  • the cache write commits in its own REQUIRES_NEW transaction

Fingerprint rules are route-specific and payload-aware:

  • StudentController.bulkImportStudents(...) fingerprints fileSize + sha256
  • FileController.uploadFile(...) fingerprints entity context plus file metadata and byte hash
  • SmsController uses executeOnceWithoutResponseBody(...) so replay metadata is cached without storing response-body PII

Current drift to know about

The inspected sources disagree on whether long-job creators require the header:

  • .codex/knowledge/DOMAIN_GLOSSARY.md says idempotency is required on "billing + long-job creators"
  • the live long-job exam controllers currently mark Idempotency-Key as optional and only enable replay protection when the caller sends it

This page follows the live controller code in ResultController, ReportController, and JoossamengAiReportController.

Long-Job 202 + Location + statusUrl Contract

Asynchronous exam-side POSTs share the same acceptance envelope:

  • HTTP status 202 Accepted
  • Location header set to the polling URL
  • JobAcceptedResponse(Long jobId, String statusUrl) body
  • a companion GET status endpoint at the same statusUrl

Exact controller pattern from ResultController.acceptedOmrJob(...):

private ResponseEntity<JobAcceptedResponse> acceptedOmrJob(Long examId, OmrJobResponse response) {
String statusUrl = "/v1/exams/" + examId + "/omr-jobs/" + response.jobId();
return ResponseEntity.status(HttpStatus.ACCEPTED)
.location(URI.create(statusUrl))
.body(new JobAcceptedResponse(response.jobId(), statusUrl));
}

Live routes using that contract:

POST routeAcceptance bodyStatus endpoint
POST /v1/exams/{examId}/results/omr/batch/confirmJobAcceptedResponseGET /v1/exams/{examId}/omr-jobs/{jobId}
POST /v1/exams/{examId}/reports/batchJobAcceptedResponseGET /v1/exams/{examId}/reports/jobs/{jobId}
POST /v1/exams/{examId}/reports/ai-batchJobAcceptedResponseGET /v1/exams/{examId}/reports/ai-jobs/{jobId}

Exact example, anchored to ResultController:

POST /v1/exams/15/results/omr/batch/confirm
Idempotency-Key: omr-batch-15
Content-Type: application/json
{
"batchKey": "20260614-01",
"objectKeys": [
"tmp/15/omr/20260614-01/page-1.png",
"tmp/15/omr/20260614-01/page-2.png"
]
}
HTTP/1.1 202 Accepted
Location: /v1/exams/15/omr-jobs/341
{
"jobId": 341,
"statusUrl": "/v1/exams/15/omr-jobs/341"
}

Intentional contract exceptions:

  • POST /v1/exams/{examId}/results/omr is still synchronous and returns 201 Created with the graded result
  • POST /v1/students/bulk is synchronous by design and returns the full BulkImportResult inline

Partial Success For Bulk-Like Operations

Bulk-like does not always mean "spawn a job". The current backend uses two different contracts, both intentionally visible in the status code and body.

207 Multi-Status for batch lifecycle mutations

StudentController uses 207 Multi-Status for:

  • POST /v1/students/batch/deactivate
  • POST /v1/students/batch/reactivate
  • POST /v1/students/batch/delete

Those routes return BatchOperationResult:

{
"total": 3,
"success": 2,
"failed": 1,
"failures": [
{
"id": 42,
"reason": "Student is still active"
}
]
}

200 OK with row-level errors for synchronous import

POST /v1/students/bulk stays inline and returns BulkImportResult even when some rows fail:

{
"totalRows": 2,
"successCount": 1,
"failureCount": 1,
"errors": [
{
"rowNumber": 3,
"column": "phone",
"value": "010-12",
"code": "STUDENT_502",
"message": "..."
}
]
}

Caller rule:

  • 207 Multi-Status means inspect failures[]
  • 200 OK on bulk import still requires inspecting failureCount and errors[]

URL And Naming Policy

The live controllers use a noun-first resource layout, but they do not force every mutation into a single CRUD shape.

Current policy, grounded in route usage:

  • resource collections stay under plural nouns such as /v1/students, /v1/classes, /v1/exams
  • lifecycle transitions and workflow steps use explicit action suffixes when the operation is not a plain partial update
  • worker-only read routes stay under /internal/...

Representative action-style routes from the current code:

  • /v1/students/{id}/deactivate
  • /v1/students/{id}/reactivate
  • /v1/students/{id}/reset-password
  • /v1/exams/{examId}/results/omr/batch/presign
  • /v1/exams/{examId}/results/omr/batch/confirm
  • /v1/qna/{id}/comments

/full means a richer projection, not sparse fieldsets

The backend does not expose a generic expand or sparse-fieldset system today. When it needs a second, richer read model, it uses the /full suffix:

  • GET /v1/exams/{id}/full
  • GET /internal/omr/exams/{id}/full

/full is therefore a projection contract, not a hint that callers can choose arbitrary field subsets.

/v1/qna is a deliberate acronym-path exception

QnaController uses /v1/qna, and the domain glossary documents that path as a recognized acronym-namespace exception.

Keep the current shape:

  • do not pluralize it to /v1/qnas
  • do not rename it to /v1/questions unless the canonical Qna* domain terms also change

The same glossary note calls out sibling exceptions such as /v1/sms and /v1/staff.

Datetime And Scalar Shape Policy

The backend's wire shapes are intentionally narrower than the underlying JVM types.

  • DTO timestamps use Instant, not LocalDateTime; backend rules and linting treat timezone-naive LocalDateTime in DTOs as a contract bug
  • nullable response keys must be annotated with @Schema(nullable = true) so codegen emits T | null rather than a lying non-null type
  • response keys still stay present because OpenApiResponseRequiredCustomizer marks response-only properties as required

Examples from the inspected DTOs:

  • ClassResponse.createdAt and updatedAt are Instant
  • ExamResponse, ExamFullResponse, and OmrJobStatusResponse also expose timestamps as Instant
  • SchedulePatternResponse.startTime, endTime, and nested session times are strings, not LocalTime objects, because OpenApiConfig maps LocalTime to the "HH:mm" wire shape

Representative schedule shape, anchored to SchedulePatternResponse:

{
"days": ["MON", "WED"],
"startTime": "09:00",
"endTime": "11:00",
"sessions": [
{
"day": "MON",
"startTime": "09:00",
"endTime": "11:00"
}
]
}

Observability And Verification

Contract observability comes from both runtime responses and drift gates:

  • GlobalExceptionHandler adds code, timestamp, instance, and MDC-derived tenantSlug so client-visible errors can be correlated with logs
  • OpenApiSnapshotTest is the producer-side contract drift gate
  • snapshotOpenApi plus orval generation is the consumer-side handoff path
  • queue-backed long-job runtime details live in the backend messaging and exam module references

Useful verification commands:

cd /Users/bluemayne/Projects/Lumie/lumie-backend
./gradlew :app:test --tests '*OpenApiSnapshotTest'
./gradlew :libs:common:test --tests '*Idempotency*'
./gradlew :modules:exam:test --tests '*ResultController*'
./gradlew :modules:student:test --tests '*StudentController*'

Expected success signals:

  • OpenApiSnapshotTest still matches the committed backend snapshot
  • the idempotency tests still prove replay, collision, and header-validation behavior
  • exam and student controller tests still cover the shared envelopes described on this page