openapi: 3.1.0
info:
  title: Cerova LabelCore API
  version: 0.1.0-alpha
  summary: Governed, bilingual (EN/ES) US FDA drug-label information.
  description: |
    Read-only public API for **Cerova LabelCore** (backend: Product Information
    Control Plane). Serves US FDA drug labels normalised to FHIR ePI, with
    patient-facing sections translated to Spanish behind a numeric/unit safety
    gate and human review.

    **For native/mobile (Flutter) consumers:**
    - All reads are cache-friendly: send `If-None-Match` with the last `ETag` to
      get `304 Not Modified` and save bandwidth.
    - Use `GET /products/{id}/manifest` for **offline delta sync** — it returns a
      per-section content hash per language; re-fetch only sections whose hash
      changed.
    - Spanish (`es`) content is **unofficial**: you MUST display the returned
      `disclaimer` and the `officialSource` link, and keep the English source
      reachable.
    - Section bodies are pre-sanitised HTML (`p, ul, ol, li, table, strong, em,
      sub, sup, br, a`). Render with a hardened HTML widget; tag Spanish content
      `lang="es"` for correct screen-reader pronunciation.
  contact:
    name: Cerova LabelCore
    url: https://api.cerova.io
  license:
    name: Proprietary (alpha)
servers:
  - url: https://api.cerova.io/v1
    description: Alpha
  - url: https://lumen.james-564.workers.dev/v1
    description: Alpha (workers.dev fallback)
security:
  - ApiKeyAuth: []

tags:
  - name: Discovery
    description: Search and product lookup
  - name: Content
    description: Leaflets, sections, manifests, FHIR
  - name: Meta
    description: Health and coverage (no auth)

paths:
  /health:
    get:
      tags: [Meta]
      summary: Liveness check
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  service: { type: string }
                  ts: { type: string, format: date-time }
  /stats:
    get:
      tags: [Meta]
      summary: Corpus coverage statistics
      security: []
      responses:
        "200":
          description: Coverage snapshot
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stats" }

  /search:
    get:
      tags: [Discovery]
      summary: Typeahead search by name, NDC, or active ingredient
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2 }
          description: Query (≥2 chars). Prefix-matched on the last token for name search.
        - name: type
          in: query
          required: false
          schema: { type: string, enum: [name, ndc, ingredient], default: name }
        - name: lang
          in: query
          required: false
          schema: { type: string, enum: [en, es] }
      responses:
        "200":
          description: Matches (max 20)
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  query: { type: string }
                  type: { type: string }
                  count: { type: integer }
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/SearchResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /products/{id}:
    get:
      tags: [Discovery]
      summary: Product summary
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Product summary
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductSummary" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{id}/leaflet:
    get:
      tags: [Content]
      summary: Full patient leaflet, language-negotiated
      description: |
        Returns the rendered patient leaflet. Language is chosen by `?lang=` then
        `Accept-Language`, default `en`. If the requested language is not
        published, returns `404` with `availableLangs`.
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: Leaflet
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            Cache-Control: { schema: { type: string }, description: "public, max-age=… (immutable per version)" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Leaflet" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Product not found, or language not published
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      availableLangs:
                        type: array
                        items: { type: string }

  /products/{id}/sections/{loinc}:
    get:
      tags: [Content]
      summary: Single leaflet section by LOINC code
      parameters:
        - $ref: "#/components/parameters/Id"
        - name: loinc
          in: path
          required: true
          schema: { type: string }
          example: "34071-1"
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: One section with provenance envelope
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  setId: { type: string }
                  version: { type: integer }
                  lang: { type: string }
                  official: { type: boolean }
                  officialSource: { type: string, format: uri }
                  disclaimer: { type: string }
                  section: { $ref: "#/components/schemas/Section" }
        "404": { $ref: "#/components/responses/NotFound" }

  /products/{id}/manifest:
    get:
      tags: [Content]
      summary: Per-section content manifest (offline delta sync)
      description: |
        Per-section content hashes per language. Mobile clients cache leaflets
        offline and, on reconnect, fetch this manifest and re-download only the
        sections whose hash changed.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Manifest
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Manifest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /fhir/Composition/{id}:
    get:
      tags: [Content]
      summary: FHIR ePI document Bundle (standards-based consumers)
      parameters:
        - $ref: "#/components/parameters/Id"
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: FHIR R4 document Bundle (Composition + MedicinalProductDefinition)
          content:
            application/fhir+json:
              schema: { type: object, description: "FHIR Bundle (type=document)" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: API key issued by Cerova. Send on every request except /health and /stats.

  parameters:
    Id:
      name: id
      in: path
      required: true
      description: Product identifier — a DailyMed **Set ID** (UUID) or an **NDC** code.
      schema: { type: string }
      example: "6919399e-f112-4274-b4de-df0b4c391e63"
    Lang:
      name: lang
      in: query
      required: false
      description: Language. Falls back to `Accept-Language`, then `en`.
      schema: { type: string, enum: [en, es] }

  headers:
    ETag:
      description: Weak/strong validator = content version. Echo as `If-None-Match` for 304.
      schema: { type: string }

  responses:
    NotModified:
      description: Content unchanged (matched `If-None-Match`).
    BadRequest:
      description: Invalid request
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Missing or invalid API key (header `x-api-key`)
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Not found
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: Rate limit exceeded
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
    SearchResult:
      type: object
      properties:
        setId: { type: string }
        brandName: { type: string, nullable: true }
        genericName: { type: string, nullable: true }
        ndcs: { type: array, items: { type: string } }
        docType: { type: string, enum: [otc, rx, other] }
        usageRank: { type: integer, nullable: true }
        languages: { type: array, items: { type: string, enum: [en, es] } }
    ProductSummary:
      type: object
      properties:
        setId: { type: string }
        brandName: { type: string, nullable: true }
        genericName: { type: string, nullable: true }
        activeIngredients: { type: array, items: { type: string } }
        ndcs: { type: array, items: { type: string } }
        manufacturer: { type: string, nullable: true }
        title: { type: string }
        docType: { type: string, enum: [otc, rx, other] }
        usageRank: { type: integer, nullable: true }
        version: { type: integer, nullable: true }
        languages: { type: array, items: { type: string } }
        patientFacingSections: { type: integer }
        officialSource: { type: string, format: uri }
    Section:
      type: object
      properties:
        loinc: { type: string, description: "LOINC section code" }
        title: { type: string }
        html: { type: string, description: "Sanitised HTML body" }
        riskTier: { type: string, enum: [none, review] }
        isPatientFacing: { type: boolean }
        translationStatus:
          type: string
          enum: [n/a, mt, gate_failed, in_review, approved]
          nullable: true
    Leaflet:
      type: object
      properties:
        setId: { type: string }
        docId: { type: string }
        version: { type: integer }
        lang: { type: string, enum: [en, es] }
        official: { type: boolean, description: "true for EN (verbatim FDA); false for ES (unofficial)" }
        officialSource: { type: string, format: uri }
        productTitle: { type: string }
        brandName: { type: string, nullable: true }
        genericNames: { type: array, items: { type: string } }
        ndcs: { type: array, items: { type: string } }
        docType: { type: string, enum: [otc, rx, other] }
        disclaimer: { type: string, description: "MUST be displayed (esp. for ES)" }
        translatedAt: { type: string, format: date-time, nullable: true }
        engine: { type: string, nullable: true, description: "translation engine@version (provenance)" }
        reviewStatus: { type: string, nullable: true }
        sections:
          type: array
          items: { $ref: "#/components/schemas/Section" }
    Manifest:
      type: object
      properties:
        setId: { type: string }
        docId: { type: string }
        languages: { type: array, items: { type: string } }
        sections:
          type: array
          items:
            type: object
            properties:
              loinc: { type: string }
              title: { type: string, nullable: true }
              isPatientFacing: { type: boolean }
              riskTier: { type: string, enum: [none, review], nullable: true }
              hashes:
                type: object
                additionalProperties: { type: string, nullable: true }
                description: "lang → content hash, e.g. { en: '…', es: '…' }"
              translationStatus:
                type: object
                additionalProperties: { type: string, nullable: true }
    Stats:
      type: object
      properties:
        products: { type: integer }
        productsWithEs: { type: integer }
        documents: { type: integer }
        patientFacingSectionsEn: { type: integer }
        esSectionsPublishable: { type: integer }
        esSectionsInReview: { type: integer }
        esSectionsGateFailed: { type: integer }
        sourceSegments: { type: integer }
        sourceSegmentsTranslated: { type: integer }
        segmentCoveragePct: { type: number }
        reviewQueuePending: { type: integer }
        generatedAt: { type: string, format: date-time }
