Skip to content

Instantly share code, notes, and snippets.

@AspireOne
Created March 30, 2026 10:19
Show Gist options
  • Select an option

  • Save AspireOne/9d48fb76239e66c283ba237ca70d8935 to your computer and use it in GitHub Desktop.

Select an option

Save AspireOne/9d48fb76239e66c283ba237ca70d8935 to your computer and use it in GitHub Desktop.
V2 of API spec

Examination Form API Spec Simplified

Purpose

This document describes the simplified frontend/backend contract for the examination form.

The goals are:

  • keep the frontend structure hardcoded
  • keep the backend as the source of truth for values, visibility, status, and finalization
  • avoid arbitrary dynamic section or subsection identifiers
  • keep autosave safe with optimistic concurrency
  • keep the payload predictable and easy to map to the database

Core Rules

  • the frontend keeps the route structure and UI layout
  • the backend exposes one concrete endpoint per section
  • section keys are a closed enum, not arbitrary strings
  • subsection keys are a closed enum per section, not arbitrary strings
  • the backend returns only the subsections the current client may see
  • section saves always send the whole visible section snapshot
  • section saves use PUT, not sparse PATCH
  • all section reads and writes carry a revision

Closed Section Keys

These are the canonical section keys used in payloads:

type ExaminationSectionKey =
  | "anamneza"
  | "predbezne-testy"
  | "vlastni-korekce-a-obj-ref"
  | "subjektivni-refrakce"
  | "binokularita"
  | "mereni-blizko"
  | "zaver";

Important:

  • these keys are part of the API contract
  • they are not user-provided free-form values
  • the backend should model them as code constants / enums

Closed Subsection Keys

Subsection keys should also be code-defined, per section.

Example for anamneza:

type AnamnesisSubsectionKey =
  | "subjective-history"
  | "correction-history"
  | "digital-history"
  | "social-history"
  | "medical-history";

Important:

  • subsection keys are payload contract values, not arbitrary database strings
  • the backend should validate them against the section they belong to
  • the frontend should map them to hardcoded subsection components

Section Status

type ExaminationSectionStatus = "not_started" | "incomplete" | "error" | "complete";

Meaning:

  • not_started
    • no meaningful answer exists in the section
  • incomplete
    • no invalid values exist, but one or more required visible fields are missing
  • error
    • at least one entered value is invalid
  • complete
    • all required visible fields are present and valid

Important:

  • missing required values must produce incomplete, not error
  • error is only for invalid entered values

Field Issues

type ExaminationFieldIssueKind = "invalid" | "missing_required";

type ExaminationFieldIssueDto = {
  propertyPath: string;
  message: string;
  kind: ExaminationFieldIssueKind;
  code?: string;
  hint?: string;
};

Rules:

  • propertyPath must use a stable, documented dot-path format
  • array items should use numeric segments, for example:
    • digitalHistory.deviceRows.0.dailyHours
    • socialHistory.activityRows.1.note
  • invalid means a provided value is not acceptable
  • missing_required means a required visible field has no value

Null vs Omission Rules

This part is critical.

Visible empty scalar field

If a scalar field is visible and empty, it must be sent and returned as null.

Examples:

  • text input empty -> null
  • date input empty -> null
  • select empty -> null
  • number-like scalar empty -> null

Visible empty collection

If a collection field is visible and empty, it must be sent and returned as [].

Hidden subsection

If a subsection is hidden for the current client:

  • it is omitted from subsections
  • it is omitted from values

Why omission is not used for empty visible fields

Because omission is ambiguous. It could mean:

  • hidden
  • unchanged
  • not loaded
  • not included by mistake
  • intentionally cleared

Using null and [] keeps the contract explicit.

Revision and Safe Autosave

To avoid stale saves overwriting newer changes, every read and write includes a revision.

Rules:

  • GET responses include the current revision
  • PUT requests must send the last known revision
  • if the submitted revision is stale, the backend rejects the write

Suggested stale-write status code:

  • 409 Conflict

1. Get Form Shell

Returns exam-wide metadata for the sidebar and layout.

Endpoint

GET /api/examinations/{examinationId}/form-shell

Response

{
  "examinationId": "ex_123",
  "revision": 7,
  "state": "draft",
  "sections": [
    {
      "key": "anamneza",
      "status": "incomplete",
      "subsections": [
        "subjective-history",
        "correction-history",
        "digital-history",
        "social-history"
      ]
    },
    {
      "key": "predbezne-testy",
      "status": "not_started",
      "subsections": [
        "motility-pupils",
        "cover-test"
      ]
    }
  ]
}

Meaning:

  • sections[].key is a closed section enum value
  • sections[].subsections contains only the subsection keys visible to the current client
  • the frontend uses this for navigation, visibility filtering, and read-only state

2. Concrete Section Endpoints

The backend should keep one concrete endpoint per section.

Examples:

  • GET /api/examinations/{examinationId}/anamnesis
  • PUT /api/examinations/{examinationId}/anamnesis
  • GET /api/examinations/{examinationId}/preliminary-tests
  • PUT /api/examinations/{examinationId}/preliminary-tests
  • GET /api/examinations/{examinationId}/custom-correction-and-objective-refraction
  • PUT /api/examinations/{examinationId}/custom-correction-and-objective-refraction

Important:

  • the frontend may keep a generic route like /:section
  • the backend should not expose one generic /{sectionName} resource whose DTO changes dynamically
  • each backend route should map to one concrete DTO, provider, and processor

3. Get Section

Loads one section.

Endpoint

GET /api/examinations/{examinationId}/anamnesis

Response

{
  "examinationId": "ex_123",
  "revision": 7,
  "section": "anamneza",
  "status": "incomplete",
  "subsections": [
    "subjective-history",
    "correction-history",
    "digital-history",
    "social-history"
  ],
  "values": {
    "subjectiveHistory": {
      "reason": "Blurred vision",
      "eyeHistory": null
    },
    "correctionHistory": {
      "glassesSince": null,
      "correctionNote": null,
      "lastOphthalmologist": "2025-10-12",
      "ophthalmologistNote": null
    },
    "digitalHistory": {
      "deviceRows": []
    },
    "socialHistory": {
      "activityRows": [],
      "driverValue": null,
      "driverNote": null
    }
  },
  "fieldIssues": [
    {
      "propertyPath": "correctionHistory.lastOphthalmologist",
      "kind": "invalid",
      "message": "Date cannot be in the future"
    }
  ]
}

Rules:

  • subsections contains only visible subsection keys for this section
  • values contains only visible subsection objects
  • visible empty scalar fields must be present as null
  • visible empty arrays must be present as []
  • hidden subsections are omitted entirely

4. Save Section

Saves one section.

The frontend may trigger autosave after editing one field, but the request payload must still contain the whole visible section snapshot.

This means:

  • user edits one field locally
  • frontend serializes the entire current visible section state
  • frontend sends that full visible section in one PUT

This keeps backend merge logic simple and makes validation deterministic.

Endpoint

PUT /api/examinations/{examinationId}/anamnesis

Request

{
  "revision": 7,
  "values": {
    "subjectiveHistory": {
      "reason": "Blurred vision",
      "eyeHistory": null
    },
    "correctionHistory": {
      "glassesSince": null,
      "correctionNote": null,
      "lastOphthalmologist": "2025-10-12",
      "ophthalmologistNote": null
    },
    "digitalHistory": {
      "deviceRows": []
    },
    "socialHistory": {
      "activityRows": [],
      "driverValue": null,
      "driverNote": null
    }
  }
}

Response

{
  "examinationId": "ex_123",
  "revision": 8,
  "section": "anamneza",
  "status": "incomplete",
  "subsections": [
    "subjective-history",
    "correction-history",
    "digital-history",
    "social-history"
  ],
  "values": {
    "subjectiveHistory": {
      "reason": "Blurred vision",
      "eyeHistory": null
    },
    "correctionHistory": {
      "glassesSince": null,
      "correctionNote": null,
      "lastOphthalmologist": "2025-10-12",
      "ophthalmologistNote": null
    },
    "digitalHistory": {
      "deviceRows": []
    },
    "socialHistory": {
      "activityRows": [],
      "driverValue": null,
      "driverNote": null
    }
  },
  "fieldIssues": [],
  "sections": [
    {
      "key": "anamneza",
      "status": "incomplete",
      "subsections": [
        "subjective-history",
        "correction-history",
        "digital-history",
        "social-history"
      ]
    },
    {
      "key": "predbezne-testy",
      "status": "not_started",
      "subsections": [
        "motility-pupils",
        "cover-test"
      ]
    }
  ]
}

Stale Revision Failure

{
  "error": "revision_conflict",
  "message": "The examination was changed by a newer save.",
  "currentRevision": 8
}

5. Finalize Examination

Finalization runs a full examination validation and locks the examination.

Endpoint

POST /api/examinations/{examinationId}/finalize

Success Response

{
  "examinationId": "ex_123",
  "revision": 9,
  "state": "finalized",
  "sections": [
    {
      "key": "anamneza",
      "status": "complete",
      "subsections": [
        "subjective-history",
        "correction-history",
        "digital-history",
        "social-history"
      ]
    },
    {
      "key": "predbezne-testy",
      "status": "complete",
      "subsections": [
        "motility-pupils",
        "cover-test"
      ]
    }
  ]
}

Validation Failure Response

Suggested status code:

  • 422 Unprocessable Entity
{
  "examinationId": "ex_123",
  "revision": 8,
  "state": "draft",
  "sections": [
    {
      "key": "anamneza",
      "status": "complete",
      "subsections": [
        "subjective-history",
        "correction-history",
        "digital-history",
        "social-history"
      ]
    },
    {
      "key": "predbezne-testy",
      "status": "incomplete",
      "subsections": [
        "motility-pupils",
        "cover-test"
      ]
    }
  ],
  "sectionIssues": [
    {
      "section": "predbezne-testy",
      "fieldIssues": [
        {
          "propertyPath": "coverTest.distance",
          "kind": "missing_required",
          "message": "This field is required"
        }
      ]
    }
  ]
}

Persistence Guidance

To keep the API and backend implementation maintainable:

  • do not build persistence around generic rows like:
    • exam_id
    • section_key
    • subsection_key
    • field_key
    • value
  • avoid turning the form into an EAV model
  • prefer typed section data in the domain model
  • persist section data either:
    • in typed columns / owned tables per section, or
    • as structured JSON per section

Important:

  • subsection keys are API contract values
  • they do not need to become the primary persistence model
  • subsections is visibility metadata derived by backend rules for the current client

Summary

  • section keys are closed contract values
  • subsection keys are closed contract values per section
  • the shell returns subsections, not visibleSubsections
  • the backend exposes concrete section endpoints
  • section reads and writes include revision
  • saves use full visible-section PUT
  • visible empty scalar fields use null
  • visible empty collections use []
  • hidden subsections are omitted
  • finalization is a dedicated locking action
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment