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
| Path | Role |
|---|---|
lumie-backend/AGENTS.md | Backend-wide API rules such as Instant in DTOs, PageResponse<T>, and BusinessException usage |
lumie-backend/app/build.gradle.kts | springdoc-openapi dependency and snapshotOpenApi task |
lumie-backend/app/src/main/java/com/lumie/app/config/OpenApiConfig.java | Base OpenAPI config, bearer auth scheme, and LocalTime string mapping |
lumie-backend/app/src/main/java/com/lumie/app/config/OpenApiResponseRequiredCustomizer.java | Marks response-only schema properties as required for codegen accuracy |
lumie-backend/app/src/main/java/com/lumie/app/config/OpenApiPageableFlattenCustomizer.java | Flattens Pageable to page, size, and sort query params |
lumie-backend/app/src/test/java/com/lumie/app/OpenApiSnapshotTest.java | Backend-side OpenAPI drift gate |
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/BusinessException.java | Base business failure type |
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/GlobalExceptionHandler.java | Global ProblemDetail mapper |
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/QuotaExceededException.java | Example typed exception with extra response properties |
lumie-backend/libs/common/src/main/java/com/lumie/common/api/PageResponse.java | Required list-response envelope |
lumie-backend/libs/common/src/main/java/com/lumie/common/api/JobAcceptedResponse.java | Shared long-job acceptance body |
lumie-backend/libs/common/src/main/java/com/lumie/common/idempotency/IdempotencyHeaderInterceptor.java | Header enforcement policy |
lumie-backend/libs/common/src/main/java/com/lumie/common/idempotency/IdempotencyService.java | Replay, fingerprint, and cached-response semantics |
lumie-backend/modules/student/src/main/java/com/lumie/student/adapter/in/web/StudentController.java | Inline 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}.java | Long-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.md | Canonical explanation for the /v1/qna acronym-path exception |
lumie-frontend/orval.config.ts | Consumer-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:
OpenApiConfigdeclares the shared bearer-auth scheme and rewritesjava.time.LocalTimeto a string schema with an09:00example, so frontend codegen sees time-of-day fields as strings instead of Java object shapes.OpenApiResponseRequiredCustomizermarks response-only DTO properties as required, which keeps generated TypeScript types asfield: Trather thanfield?: Twhen Java records always emit the key.OpenApiPageableFlattenCustomizerreplaces nestedPageableparams with flatpage,size, andsortquery params without breaking multipart upload routes.
The handoff to the frontend is explicit:
OpenApiSnapshotTestcompares the live spec to the committed backend golden atapp/src/test/resources/openapi/api-docs.json../gradlew snapshotOpenApiexports the running backend's spec intolumie-frontend/openapi.json.lumie-frontend/orval.config.tsthen generates per-entity clients intosrc/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, andtenantSlugwhen present in MDC - Typed exception properties from
ExtensionProperties, such asQuotaExceededExceptionaddingmetricType,currentUsage, andlimit - Validation failures add
violations[]withfield,message, andrejectedValue
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 repeatedsortparams - callers do not send a nested
pageableobject - pageable endpoints should also declare
@AllowedSortFields(...)
Idempotency Policy
The idempotency layer has two parts.
HTTP-boundary enforcement:
IdempotencyHeaderInterceptorenforcesIdempotency-Keyon mutating billing routes plusPOST /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(...)orexecuteOnceWithoutResponseBody(...)explicitly - scope is
(tenantSlug, endpointPath, idempotencyKey) - same key plus same canonical fingerprint replays the cached status and body
- same key plus different fingerprint raises
IdempotencyConflictExceptionand returns409 Conflict - the cache write commits in its own
REQUIRES_NEWtransaction
Fingerprint rules are route-specific and payload-aware:
StudentController.bulkImportStudents(...)fingerprintsfileSize + sha256FileController.uploadFile(...)fingerprints entity context plus file metadata and byte hashSmsControllerusesexecuteOnceWithoutResponseBody(...)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.mdsays idempotency is required on "billing + long-job creators"- the live long-job exam controllers currently mark
Idempotency-Keyas 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 Locationheader set to the polling URLJobAcceptedResponse(Long jobId, String statusUrl)body- a companion
GETstatus endpoint at the samestatusUrl
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 route | Acceptance body | Status endpoint |
|---|---|---|
POST /v1/exams/{examId}/results/omr/batch/confirm | JobAcceptedResponse | GET /v1/exams/{examId}/omr-jobs/{jobId} |
POST /v1/exams/{examId}/reports/batch | JobAcceptedResponse | GET /v1/exams/{examId}/reports/jobs/{jobId} |
POST /v1/exams/{examId}/reports/ai-batch | JobAcceptedResponse | GET /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/omris still synchronous and returns201 Createdwith the graded resultPOST /v1/students/bulkis synchronous by design and returns the fullBulkImportResultinline
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/deactivatePOST /v1/students/batch/reactivatePOST /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-Statusmeans inspectfailures[]200 OKon bulk import still requires inspectingfailureCountanderrors[]
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}/fullGET /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/questionsunless the canonicalQna*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, notLocalDateTime; backend rules and linting treat timezone-naiveLocalDateTimein DTOs as a contract bug - nullable response keys must be annotated with
@Schema(nullable = true)so codegen emitsT | nullrather than a lying non-null type - response keys still stay present because
OpenApiResponseRequiredCustomizermarks response-only properties as required
Examples from the inspected DTOs:
ClassResponse.createdAtandupdatedAtareInstantExamResponse,ExamFullResponse, andOmrJobStatusResponsealso expose timestamps asInstantSchedulePatternResponse.startTime,endTime, and nested session times are strings, notLocalTimeobjects, becauseOpenApiConfigmapsLocalTimeto 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:
GlobalExceptionHandleraddscode,timestamp,instance, and MDC-derivedtenantSlugso client-visible errors can be correlated with logsOpenApiSnapshotTestis the producer-side contract drift gatesnapshotOpenApiplus 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:
OpenApiSnapshotTeststill 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