openapi: 3.1.0

info:
  title: Zapla Network API
  version: 0.1.0-SNAPSHOT
  summary: Read-only public data for the Zapla Network.
  description: |
    A thin read layer over the services that own the truth. Everything here is public,
    read-only and cached; no authentication is required and none is accepted yet.

    **Rate limits** are published on every response as `X-RateLimit-Limit`,
    `X-RateLimit-Remaining` and `X-RateLimit-Reset`. A refused request answers `429` with
    `Retry-After`. A gateway shedding load answers `503` with `Retry-After`.

    **Money is always an integer in minor units** (credits x 100). The `credits` string beside
    it is a convenience for humans and must never be parsed back into a number.

    **Caching**: cacheable responses carry a strong `ETag`. Send it back as `If-None-Match`
    and a `304` costs you headers instead of a body.

    **Errors** are RFC 9457 problem documents, one shape for the whole API. A path that does
    not exist answers `404`; a path that exists but not for that method answers `405` with an
    `Allow` header. Neither is listed per-operation below because neither is a property of any
    one operation.

    **CORS**: `Access-Control-Allow-Origin: *`, and `Access-Control-Expose-Headers` lists
    `ETag`, `Retry-After`, `X-Request-Id` and the three `X-RateLimit-*` headers. Browser
    clients can only read response headers named there; everything else in a cross-origin
    response is invisible to JavaScript however it was sent. Non-browser clients are
    unaffected.
  license:
    name: Proprietary
  contact:
    name: Zapla Network
    url: https://zapla.net

servers:
  - url: https://api.zapla.net
    description: Production
  - url: http://127.0.0.1:8080
    description: Local development

tags:
  - name: meta
    description: Discovery, health and the document you are reading.
  - name: status
    description: Whether the network is up and who is on it.
  - name: market
    description: Station prices and price history.

paths:
  /v1:
    get:
      tags: [meta]
      operationId: getIndex
      summary: Service discovery
      description: Where the OpenAPI document lives, which endpoints exist and what the limits are.
      responses:
        "200":
          description: The discovery document.
          headers:
            Cache-Control:
              schema: { type: string, examples: ["public, max-age=300"] }
            ETag: { $ref: "#/components/headers/ETag" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Index" }
        "304": { $ref: "#/components/responses/NotModified" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Overloaded" }

  /v1/status:
    get:
      tags: [status]
      operationId: getStatus
      summary: Network status
      description: |
        Answers `200` whenever the gateway itself is healthy. An unreachable proxy is the
        answer, not an error: `online` is `false` and `error` is `"unreachable"`.

        `stale: true` means the last refresh failed and this is the previous good answer.

        `sample` is present only when the server is configured to publish player names.
      responses:
        "200":
          description: The network's status as of `checked_at`.
          headers:
            Cache-Control:
              schema: { type: string, examples: ["public, max-age=10"] }
            ETag: { $ref: "#/components/headers/ETag" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Status" }
              examples:
                online:
                  summary: The network is up
                  value:
                    online: true
                    players: { online: 7, max: 200 }
                    motd: "Zapla Network"
                    version: { name: "Velocity 4.1.1", protocol: 771 }
                    latency_ms: 3
                    checked_at: "2026-09-16T12:00:00Z"
                    stale: false
                offline:
                  summary: The proxy could not be reached
                  value:
                    online: false
                    players: { online: 0, max: 0 }
                    motd: null
                    version: null
                    latency_ms: null
                    checked_at: "2026-09-16T12:00:00Z"
                    stale: false
                    error: "unreachable"
        "304": { $ref: "#/components/responses/NotModified" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Overloaded" }

  /v1/market/stations:
    get:
      tags: [market]
      operationId: listStations
      summary: Every station
      description: The station registry. Ordered by id, so the `ETag` only changes when a station does.
      responses:
        "200":
          description: The station list.
          headers:
            Cache-Control:
              schema: { type: string, examples: ["public, max-age=300"] }
            ETag: { $ref: "#/components/headers/ETag" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stations" }
        "304": { $ref: "#/components/responses/NotModified" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamUnavailable" }
        "503": { $ref: "#/components/responses/Overloaded" }

  /v1/market/quotes:
    get:
      tags: [market]
      operationId: getQuotes
      summary: Standing bids
      description: |
        What stations are paying per unit right now. Omit `station` to get every station in one
        answer rather than one request per station.
      parameters:
        - name: station
          in: query
          required: false
          description: Station id. Omitted means every station.
          schema: { $ref: "#/components/schemas/Id" }
          example: alpha
      responses:
        "200":
          description: The standing bids as of `as_of`.
          headers:
            Cache-Control:
              schema: { type: string, examples: ["public, max-age=30"] }
            ETag: { $ref: "#/components/headers/ETag" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quotes" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamUnavailable" }
        "503": { $ref: "#/components/responses/Overloaded" }

  /v1/market/history:
    get:
      tags: [market]
      operationId: getPriceHistory
      summary: Price history
      description: What one station has paid for one commodity over time, newest first.
      parameters:
        - name: station
          in: query
          required: true
          schema: { $ref: "#/components/schemas/Id" }
          example: alpha
        - name: commodity
          in: query
          required: true
          schema: { $ref: "#/components/schemas/Id" }
          example: ore
        - name: limit
          in: query
          required: false
          description: Points to return. Values outside the range are clamped, not refused.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 48
      responses:
        "200":
          description: The price history.
          headers:
            Cache-Control:
              schema: { type: string, examples: ["public, max-age=60"] }
            ETag: { $ref: "#/components/headers/ETag" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/History" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamUnavailable" }
        "503": { $ref: "#/components/responses/Overloaded" }

  /v1/openapi.yaml:
    get:
      tags: [meta]
      operationId: getOpenApiDocument
      summary: This document
      responses:
        "200":
          description: The OpenAPI 3.1 document, verbatim.
          headers:
            Cache-Control:
              schema: { type: string, examples: ["public, max-age=3600"] }
            ETag: { $ref: "#/components/headers/ETag" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            text/yaml:
              schema: { type: string }
        "304": { $ref: "#/components/responses/NotModified" }
        "405": { $ref: "#/components/responses/MethodNotAllowed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /healthz:
    get:
      tags: [meta]
      operationId: getHealth
      summary: Liveness
      description: |
        Whether this process can answer, which is a different question from whether its
        upstreams are up. Never rate limited, never cached.
      responses:
        "200":
          description: The gateway is serving.
          headers:
            Cache-Control:
              schema: { type: string, examples: ["no-store"] }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, const: ok }

components:

  headers:
    ETag:
      description: Strong validator over the exact response bytes. Send it back as `If-None-Match`.
      schema: { type: string, examples: ['"6b1f2c0e4a9d8f3b1c2d3e4f5a6b7c8d"'] }
    XRequestId:
      description: Echoed from the request when syntactically sane, otherwise generated. Quote it in bug reports.
      schema: { type: string, maxLength: 64 }
    RateLimitLimit:
      description: Requests per minute allowed for this client.
      schema: { type: integer, examples: [60] }
    RateLimitRemaining:
      description: Tokens left in this client's bucket.
      schema: { type: integer, examples: [59] }
    RateLimitReset:
      description: Unix seconds at which the bucket is full again.
      schema: { type: integer, examples: [1789200000] }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, minimum: 1, examples: [1] }

  schemas:

    Id:
      type: string
      description: A station or commodity id. Lowercase letters, digits, `.`, `_` and `-`.
      pattern: "^[a-z0-9._-]{1,64}$"
      maxLength: 64

    Money:
      type: object
      description: |
        Always an integer in minor units (credits x 100). `credits` is a formatted convenience
        string, never a float, and never something to parse back into a number.
      required: [minor_units, credits]
      properties:
        minor_units: { type: integer, format: int64, examples: [1200] }
        credits: { type: string, examples: ["12.00"] }

    Index:
      type: object
      required: [service, version, openapi, endpoints, rate_limit]
      properties:
        service: { type: string, const: zapla-gateway }
        version: { type: string, examples: ["0.1.0-SNAPSHOT"] }
        openapi: { type: string, examples: ["/v1/openapi.yaml"] }
        endpoints:
          type: array
          items: { type: string }
        rate_limit:
          type: object
          required: [requests_per_minute, burst]
          properties:
            requests_per_minute: { type: integer, examples: [60] }
            burst: { type: integer, examples: [120] }

    Status:
      type: object
      required: [online, players, motd, version, latency_ms, checked_at, stale]
      properties:
        online:
          type: boolean
          description: Whether the last server-list ping succeeded.
        players:
          type: object
          required: [online, max]
          properties:
            online: { type: integer, examples: [7] }
            max: { type: integer, examples: [200] }
        motd:
          type: [string, "null"]
          description: The MOTD, flattened to plain text with legacy colour codes stripped.
        version:
          type: [object, "null"]
          required: [name, protocol]
          properties:
            name: { type: string, examples: ["Velocity 4.1.1"] }
            protocol: { type: integer, examples: [771] }
        latency_ms:
          type: [integer, "null"]
          description: Server-list ping round trip.
        sample:
          type: array
          description: Present only when the server is configured to publish player names.
          items:
            type: object
            required: [name, id]
            properties:
              name: { type: string }
              id: { type: string }
        checked_at:
          type: string
          format: date-time
          description: When the underlying ping was taken, not when this request arrived.
        stale:
          type: boolean
          description: True when the last refresh failed and this is the previous good answer.
        error:
          type: string
          description: Present only when `online` is false.
          examples: ["unreachable"]

    Stations:
      type: object
      required: [stations]
      properties:
        stations:
          type: array
          items:
            type: object
            required: [id, name]
            properties:
              id: { $ref: "#/components/schemas/Id" }
              name: { type: string, examples: ["Alpha Station"] }

    Quotes:
      type: object
      required: [as_of, quotes]
      properties:
        as_of:
          type: string
          format: date-time
          description: When the upstream answered, not when this request arrived.
        quotes:
          type: array
          items:
            type: object
            required: [station, commodity, bid]
            properties:
              station: { $ref: "#/components/schemas/Id" }
              commodity: { $ref: "#/components/schemas/Id" }
              bid: { $ref: "#/components/schemas/Money" }

    History:
      type: object
      required: [station, commodity, points]
      properties:
        station: { $ref: "#/components/schemas/Id" }
        commodity: { $ref: "#/components/schemas/Id" }
        points:
          type: array
          description: Newest first.
          items:
            type: object
            required: [at, bid]
            properties:
              at: { type: string, format: date-time }
              bid: { $ref: "#/components/schemas/Money" }

    Problem:
      type: object
      description: RFC 9457. One error shape for the whole API.
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          examples: ["https://zapla.net/errors/rate-limited"]
        title: { type: string, examples: ["rate limited"] }
        status: { type: integer, examples: [429] }
        detail: { type: string, examples: ["60 requests per minute per client"] }
        request_id: { type: string, maxLength: 64 }

  responses:

    NotModified:
      description: Your `If-None-Match` matched; the body is unchanged.
      headers:
        ETag: { $ref: "#/components/headers/ETag" }
        X-Request-Id: { $ref: "#/components/headers/XRequestId" }

    InvalidRequest:
      description: A required parameter was missing or malformed.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

    NotFound:
      description: No such resource. Returned with status `404` for any unrouted path.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

    MethodNotAllowed:
      description: |
        Status `405`. The path exists but not for this method; the API is read-only, so only
        GET, HEAD and OPTIONS are ever allowed.
      headers:
        Allow:
          schema: { type: string, examples: ["GET, HEAD, OPTIONS"] }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

    RateLimited:
      description: Too many requests from this client.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
        X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

    UpstreamUnavailable:
      description: An upstream service did not answer in time.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

    Overloaded:
      description: The gateway is shedding load.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
