openapi: 3.0.3
info:
  title: klimatsearch
  version: 0.1.0
  description: |
    Hybrid search HTTP API for [Boverket Klimatdatabas](https://www.boverket.se/sv/klimatdeklaration/klimatdatabas/).
    Same binary also serves MCP at `/mcp` (streamable) and `/mcp/sse` (legacy).

    Cite **Boverket Klimatdatabas**. This project is not affiliated with Boverket.

    Boverket's OpenAPI has no per-id GET. Each Boverket Resource includes `origin`
    (`https://klimatdatabasen.boverket.se/detaljer/{category_code}/{id}`).
    `GET /api/resources/{id}/origin` 302s there.

    Resources include typical A1–A3 (`a1a3`) plus conservative A1–A3, A4, A5.1,
    waste/conservative factors, biogenic carbon, service life, BK04, and A4
    transport legs when Boverket publishes them. Energy carriers omit A4/A5.1.

    `GET /healthz`, `GET /openapi.yaml`, and `GET /docs` are public.
    The default self-hosted build (`make build`) has no Unkey or MPP and does not
    gate `/api/*`. The binary we run is `make build-hosted` (`-tags hosted`).

    Outbound: when ingest upserts a changed Resource, klimatsearch POSTs `catalog.changed`
    to `KLIMAT_WEBHOOK_URL`. That is not an inbound path on this API.
  license:
    name: Apache-2.0
    url: https://www.apache.org/licenses/LICENSE-2.0.html
  contact:
    name: klimatsearch
    url: https://github.com/mong-x/klimatsearch
externalDocs:
  description: Human launch checklist
  url: https://github.com/mong-x/klimatsearch/blob/main/docs/LAUNCH.md
servers:
  - url: http://127.0.0.1:8080
    description: make run default
  - url: http://127.0.0.1:8081
    description: when TCP 8080 is already taken
tags:
  - name: Meta
  - name: Search
  - name: Resources
  - name: Admin
paths:
  /healthz:
    get:
      tags: [Meta]
      summary: Liveness
      operationId: healthz
      security: []
      responses:
        "200":
          description: Store reachable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
        "503":
          description: Store unreachable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
  /openapi.yaml:
    get:
      tags: [Meta]
      summary: This OpenAPI document
      operationId: openapiYaml
      security: []
      responses:
        "200":
          description: OpenAPI 3.0.3 YAML
          content:
            application/yaml:
              schema:
                type: string
  /docs:
    get:
      tags: [Meta]
      summary: Swagger UI for this process
      operationId: swaggerUI
      security: []
      responses:
        "200":
          description: HTML
          content:
            text/html:
              schema:
                type: string
  /api/search:
    get:
      tags: [Search]
      summary: Hybrid search
      description: |
        FTS5 BM25 always. sqlite-vec KNN when `vector=true` and vectors exist.
        Reciprocal Rank Fusion k=60. Each hit is the full Resource plus
        `score`, `match_source`, `details`, and `origin` when known.
      operationId: search
      parameters:
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Vector"
        - $ref: "#/components/parameters/Rerank"
        - $ref: "#/components/parameters/Databases"
      responses:
        "200":
          description: Hits
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchResponse"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
  /api/resources:
    get:
      tags: [Resources]
      summary: List Resources
      operationId: listResources
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Databases"
      responses:
        "200":
          description: All matching Catalogs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListResponse"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
  /api/resources/{id}:
    get:
      tags: [Resources]
      summary: Get one Resource
      description: |
        `{id}` may be prefixed (`boverket:6000000000`) or a bare Resource ID
        when only one Catalog matches (409 if ambiguous).
      operationId: getResource
      parameters:
        - $ref: "#/components/parameters/ResourceID"
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: Full Resource
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Resource"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
  /api/resources/{id}/origin:
    get:
      tags: [Resources]
      summary: Redirect to the Catalog's official page
      description: |
        302 to `origin` (Boverket product sheet). 404 if this Catalog has no public sheet.
      operationId: getResourceOrigin
      parameters:
        - $ref: "#/components/parameters/ResourceID"
      responses:
        "302":
          description: Location is the official sheet
          headers:
            Location:
              schema:
                type: string
                format: uri
              example: https://klimatdatabasen.boverket.se/detaljer/10/6000000000
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
  /api/resources/compare:
    get:
      tags: [Resources]
      summary: Compare climate impact of two Resources
      description: |
        Optional `unit` restates both in one unit via Conversion.
        Optional `impact` is typical (default), conservative, a4, or a5_1.
        Explicit unit that cannot apply → 400, never silent incomparable.
      operationId: compareResources
      parameters:
        - name: a
          in: query
          required: true
          schema:
            type: string
          description: First Resource ID (prefixed or bare)
        - name: b
          in: query
          required: true
          schema:
            type: string
          description: Second Resource ID
        - name: unit
          in: query
          required: false
          schema:
            type: string
          description: Shared unit (for example kg)
        - name: impact
          in: query
          required: false
          schema:
            type: string
            enum: [typical, conservative, a4, a5_1]
          description: Climate module to compare (default typical A1–A3)
      responses:
        "200":
          description: Comparison
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comparison"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/Error"
        "409":
          $ref: "#/components/responses/Error"
  /admin/ingest/file:
    post:
      tags: [Admin]
      summary: Ingest a file Catalog
      description: |
        Multipart `file` or `preview_id` from `/admin/ingest/preview`.
        Query `catalog` is required (`dkbr` or `boverket`; `br25` aliases `dkbr`).
        Form `version` is DatasetVersion (`BR18`, `BR25`, …).
        Column map fields `map.id`, `map.a1a3`, `map.name`, … pair file headers to Resource fields.
        Optional `X-Admin-Token` when `KLIMAT_ADMIN_TOKEN` is set.
      operationId: ingestFile
      parameters:
        - name: catalog
          in: query
          required: true
          schema:
            type: string
            example: dkbr
        - name: X-Admin-Token
          in: header
          required: false
          schema:
            type: string
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                preview_id:
                  type: string
                version:
                  type: string
                  example: BR25
                map.id:
                  type: string
                map.a1a3:
                  type: string
                map.name:
                  type: string
      responses:
        "200":
          description: Ingest counts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestResult"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
  /admin/ingest/preview:
    post:
      tags: [Admin]
      summary: Read file headers without upsert
      description: Returns headers, sample rows, Resource schema, and a `preview_id` to pass to `/admin/ingest/file`.
      operationId: ingestPreview
      parameters:
        - name: X-Admin-Token
          in: header
          required: false
          schema:
            type: string
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Preview
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestPreview"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /admin/resources:
    post:
      tags: [Admin]
      summary: Upsert a Resource batch
      description: Already-mapped Resources. Catalog `dkbr` or `boverket`. DatasetVersion on the body or per Resource.
      operationId: upsertResources
      parameters:
        - name: X-Admin-Token
          in: header
          required: false
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResourceBatch"
      responses:
        "200":
          description: Ingest counts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestResult"
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
components:
  securitySchemes:
    unkey:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: Unkey keyspace API key (`sk_…`). Not the server root key.
    mpp:
      type: apiKey
      in: header
      name: Authorization
      description: |
        Machine Payments Protocol. Value is `Payment <credential>`.
        Missing credential → 402 with `WWW-Authenticate: Payment`.
  parameters:
    Q:
      name: q
      in: query
      required: true
      schema:
        type: string
      description: Search Query (Swedish or English)
      example: spånskiva
    Lang:
      name: lang
      in: query
      required: false
      schema:
        type: string
        enum: [sv, en]
        default: sv
    Vector:
      name: vector
      in: query
      required: false
      schema:
        type: boolean
        default: false
    Rerank:
      name: rerank
      in: query
      required: false
      schema:
        type: boolean
        default: false
    Databases:
      name: databases
      in: query
      required: false
      schema:
        type: string
      description: Comma-separated Catalog IDs. Empty means all.
      example: boverket
    ResourceID:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: Prefixed `catalog:id` or bare Resource ID
      example: boverket:6000000000
  responses:
    Error:
      description: Error object
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Guard denied (no MPP / invalid Bearer)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    PaymentRequired:
      description: MPP challenge
      headers:
        WWW-Authenticate:
          schema:
            type: string
          example: Payment realm="klimatsearch"
      content:
        application/problem+json:
          schema:
            type: object
  schemas:
    Health:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok, unhealthy]
        error:
          type: string
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
    Resource:
      type: object
      required: [id, catalog, source]
      properties:
        id:
          type: string
          example: "6000000000"
        catalog:
          type: string
          enum: [boverket, dkbr, br25]
        name_sv:
          type: string
        name_en:
          type: string
        description_sv:
          type: string
        description_en:
          type: string
        applicability_sv:
          type: string
        applicability_en:
          type: string
        synonyms:
          type: string
        a1a3:
          type: number
          format: double
          description: Typical GWP-GHG A1–A3 kg CO2e per Declared unit (comparison / typical value, not the conservative declaration factor)
        a1a3_conservative:
          type: number
          format: double
          description: Conservative A1–A3 (~25% above typical). Boverket uses this for climate declarations; klimatsearch Compare uses typical.
        a4:
          type: number
          format: double
          description: GWP-GHG module A4 (transport to site). Omitted for energy carriers.
        a5_1:
          type: number
          format: double
          description: GWP-GHG module A5.1 (construction-site waste). Omitted for energy carriers.
        gwp_unit:
          type: string
          example: kg CO2 eq./kg
        conservative_factor:
          type: number
          format: double
        waste_factor:
          type: number
          format: double
        biogenic_carbon:
          type: number
          format: double
        service_life:
          type: string
        transports:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              distance_km:
                type: number
              type:
                type: string
              fuel:
                type: string
              fuel_resource_id:
                type: string
        unit:
          type: string
          example: kg
        conversions:
          type: object
          additionalProperties:
            type: number
            format: double
        category:
          type: string
        version:
          type: string
          example: "02.07.000"
        source:
          type: string
          example: Boverket Klimatdatabas
        origin:
          type: string
          format: uri
          description: Official Boverket product sheet when catalog is boverket
          example: https://klimatdatabasen.boverket.se/detaljer/10/6000000000
    Hit:
      allOf:
        - $ref: "#/components/schemas/Resource"
        - type: object
          properties:
            lang:
              type: string
              enum: [sv, en]
            score:
              type: number
              format: double
            match_source:
              type: string
              enum: [fts, vector, both]
              description: How retrieval matched. Reranking never rewrites this.
            reranked:
              type: boolean
              description: Present and true when a Reranker reordered the results.
            rerank_score:
              type: number
              format: double
              description: The score the Reranker assigned; the retrieval score it replaced remains in score.
            details:
              type: string
              example: /api/resources/boverket:6000000000
    SearchResponse:
      type: object
      required: [source, query, lang, results]
      properties:
        source:
          type: string
        query:
          type: string
        lang:
          type: string
        results:
          type: array
          items:
            $ref: "#/components/schemas/Hit"
    ListResponse:
      type: object
      required: [source, resources]
      properties:
        source:
          type: string
        resources:
          type: array
          items:
            $ref: "#/components/schemas/Resource"
    Comparison:
      type: object
      properties:
        source:
          type: string
        a:
          $ref: "#/components/schemas/Resource"
        b:
          $ref: "#/components/schemas/Resource"
        unit:
          type: string
        delta_a1a3:
          type: number
          format: double
        lower_impact_id:
          type: string
        incomparable:
          type: boolean
    IngestResult:
      type: object
      properties:
        catalog:
          type: string
        origin:
          type: string
          description: Ingest origin (json, excel, fixture, file) — not the Boverket sheet
        version:
          type: string
        seen:
          type: integer
        upserted:
          type: integer
        skipped:
          type: integer
    IngestPreview:
      type: object
      properties:
        preview_id:
          type: string
        filename:
          type: string
        headers:
          type: array
          items:
            type: string
        sample:
          type: array
          items:
            type: array
            items:
              type: string
        schema:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              label:
                type: string
    ResourceBatch:
      type: object
      required: [catalog, resources]
      properties:
        catalog:
          type: string
          example: dkbr
        version:
          type: string
          example: BR25
        resources:
          type: array
          items:
            type: object
            required: [id]
            properties:
              id:
                type: string
              name:
                type: string
              name_sv:
                type: string
              name_en:
                type: string
              a1a3:
                type: number
              unit:
                type: string
              category:
                type: string
              version:
                type: string
              description:
                type: string
              conversions:
                type: object
                additionalProperties:
                  type: number
                description: Conversion factors restating the declared unit (e.g. kg per m3). Send them on update or the stored ones are replaced.
                example:
                  kg/m³: 2400
              a4:
                type: number
                description: Transport-to-site GWP-GHG (kg CO2e per declared unit); omitted unless published.
              a5_1:
                type: number
                description: Construction-site waste GWP-GHG (module A5.1); omitted unless published.
              a1a3_conservative:
                type: number
                description: Conservative A1-A3 (~25% above typical), used in climate declarations.
security:
  - {}
  - unkey: []
  - mpp: []
