openapi: 3.1.0
info:
  title: Global Auto Hub — Unified Used-Car Listings API
  version: "2.0.0"
  description: |
    Unified, **normalized** REST API over multiple car marketplaces. Every offer is mapped to
    one consistent schema with currency normalization (raw + USD + EUR) — unlike per-platform
    raw feeds. Source platforms: `encar` (🇰🇷), `che168` (🇨🇳), `dongchedi` (🇨🇳),
    `dubizzle` (🇦🇪), `dubicars` (🇦🇪), `autoscout24` (🇪🇺).

    Each offer is returned in an **envelope** `{id, inner_id, change_type, created_at, data}`
    where `id` is the global monotonic change-id (use it to drive incremental sync) and `data`
    is the normalized car card. `change_type` ∈ `added | changed | removed`.

    **Authentication.** Every data endpoint requires an `api_key` (query `?api_key=` or header
    `X-API-Key`). Keys carry an optional per-source ACL and a daily quota → `401` (no/invalid key),
    `403` (source not allowed / expired / revoked), `429` (quota exceeded).

    **URL forms (both work).** Each endpoint below is the canonical form `/api/v2/{source}/...`.
    There is also an auto-api-compatible namespace form `/api/v2/{access_name}/{source}/...`, where
    `access_name` is your key's non-secret handle (shown when the key is created). When present in
    the path it must match the key's `access_name` (else `403`), binding the URL to its credential.

    **Admin key management.** Keys are issued and managed under `/api/admin/keys` (tag `admin`),
    guarded by a separate `X-Admin-Token` header (not a tenant `api_key`). The plaintext secret is
    returned only once — on creation — and is never stored (only a sha256 hash + prefix live in the DB).
servers:
  - url: https://api.global-auto-hub.ru
    description: Production
  - url: https://api.45-8-99-137.sslip.io
    description: Production (alias)
tags:
  - name: offers
    description: Browse & fetch normalized listings.
  - name: admin
    description: API-key management — issue / list / update / revoke. Requires the `X-Admin-Token` header.
security:
  - ApiKeyQuery: []
  - ApiKeyHeader: []
paths:
  /api/v2/{source}/offers:
    get:
      tags: [offers]
      operationId: listOffers
      summary: List offers (filtered, paginated, newest-changed first)
      parameters:
        - { name: source, in: path, required: true, description: Marketplace code, schema: { $ref: "#/components/schemas/Source" } }
        - { name: page, in: query, description: "Page number (1-based). OFFSET paging — fine for shallow pages; for deep/bulk download use after_change_id (O(1) at any depth).", schema: { type: integer, minimum: 1, default: 1 } }
        - { name: limit, in: query, description: "Page size. Default 20, max 1000.", schema: { type: integer, minimum: 1, maximum: 1000, default: 20 } }
        - { name: after_change_id, in: query, description: "Keyset cursor for fast deep/bulk download: pass the previous response's meta.next_cursor to fetch the next page in O(1) (constant time at any depth). next_cursor=0 means the last page. Alias: cursor.", schema: { type: integer, format: int64 } }
        - { name: cursor, in: query, description: "Alias of after_change_id.", schema: { type: integer, format: int64 } }
        - { name: mark, in: query, description: "Make (case-insensitive; alias: make). For Chinese sources (che168) this is the canonical English brand; the Chinese original is in data.extra_prep.make_zh.", schema: { type: string } }
        - { name: make, in: query, description: Alias of mark, schema: { type: string } }
        - { name: model, in: query, schema: { type: string } }
        - { name: complectation, in: query, description: Trim, schema: { type: string } }
        - { name: transmission_type, in: query, schema: { type: string } }
        - { name: color, in: query, schema: { type: string } }
        - { name: body_type, in: query, schema: { type: string } }
        - { name: engine_type, in: query, description: "Fuel/engine type (alias: fuel_type)", schema: { type: string } }
        - { name: year_from, in: query, schema: { type: integer } }
        - { name: year_to, in: query, schema: { type: integer } }
        - { name: price_from, in: query, description: Min price in EUR, schema: { type: integer } }
        - { name: price_to, in: query, description: Max price in EUR, schema: { type: integer } }
        - { name: km_age_from, in: query, description: "Min mileage (km). Alias: km_from.", schema: { type: integer } }
        - { name: km_age_to, in: query, description: "Max mileage (km). Alias: km_to.", schema: { type: integer } }
        - { name: lang, in: query, description: "Language of characteristic VALUES (brands/models always stay English). lang=ru returns body_type/fuel_type/transmission/drive_type/color/seller_type in Russian (Auto.ru standard) and adds a top-level `labels` map (field→Russian name). Default: canonical English.", schema: { type: string, enum: [en, ru], default: en } }
      responses:
        "200":
          description: A page of offers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListResponse" }
        "500": { $ref: "#/components/responses/Error" }
  /api/v2/{source}/offer:
    get:
      tags: [offers]
      operationId: getOffer
      summary: Fetch a single offer by its marketplace id (inner_id)
      parameters:
        - { name: source, in: path, required: true, schema: { $ref: "#/components/schemas/Source" } }
        - { name: inner_id, in: query, required: true, description: The id on the source platform, schema: { type: string } }
        - { name: lang, in: query, description: "lang=ru → characteristic values in Russian (see /offers).", schema: { type: string, enum: [en, ru], default: en } }
      responses:
        "200":
          description: The offer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OfferEnvelope" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /api/v2/{source}/change_id:
    get:
      tags: [offers]
      operationId: changeIdByDate
      summary: Start change-id cursor for a date (for the /changes feed)
      parameters:
        - { name: source, in: path, required: true, schema: { $ref: "#/components/schemas/Source" } }
        - { name: date, in: query, required: true, description: "yyyy-mm-dd", schema: { type: string } }
      responses:
        "200":
          description: The first change_id at/after the date.
          content:
            application/json:
              schema: { type: object, properties: { change_id: { type: integer, format: int64 } } }
        "400": { $ref: "#/components/responses/Error" }
  /api/v2/{source}/changes:
    get:
      tags: [offers]
      operationId: listChanges
      summary: Delta-sync feed — added/changed/removed events from a change_id cursor
      parameters:
        - { name: source, in: path, required: true, schema: { $ref: "#/components/schemas/Source" } }
        - { name: change_id, in: query, description: "Start change_id (inclusive); 0 = from the beginning", schema: { type: integer, format: int64 } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 1000 } }
        - { name: lang, in: query, description: "lang=ru → characteristic values in Russian (see /offers).", schema: { type: string, enum: [en, ru], default: en } }
      responses:
        "200":
          description: A page of changes with a next-cursor.
          content:
            application/json:
              schema:
                type: object
                properties:
                  result: { type: array, items: { $ref: "#/components/schemas/OfferEnvelope" } }
                  meta: { type: object, properties: { cur_change_id: { type: integer, format: int64 }, next_change_id: { type: integer, format: int64 }, limit: { type: integer } } }
  /api/v2/{source}/filters:
    get:
      tags: [offers]
      operationId: getFilters
      summary: Available filter values (mark→models tree + flat lists)
      parameters:
        - { name: source, in: path, required: true, schema: { $ref: "#/components/schemas/Source" } }
      responses:
        "200":
          description: Filter values.
          content: { application/json: { schema: { type: object } } }
  /api/admin/keys:
    post:
      tags: [admin]
      operationId: adminCreateKey
      summary: Issue an API key (DB insert). The plaintext secret is returned ONCE.
      security: [{ AdminToken: [] }]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/CreateKeyRequest" } } }
      responses:
        "201":
          description: Key created. `key` is the plaintext secret — store it now (never shown again).
          content: { application/json: { schema: { $ref: "#/components/schemas/CreateKeyResponse" } } }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
    get:
      tags: [admin]
      operationId: adminListKeys
      summary: List keys (no secrets — prefix only)
      security: [{ AdminToken: [] }]
      responses:
        "200":
          description: All keys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys: { type: array, items: { $ref: "#/components/schemas/KeyView" } }
        "401": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /api/admin/keys/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
    patch:
      tags: [admin]
      operationId: adminPatchKey
      summary: Update quota / source ACL / status / expiry
      security: [{ AdminToken: [] }]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/PatchKeyRequest" } } }
      responses:
        "200": { description: Updated. }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
    delete:
      tags: [admin]
      operationId: adminDeleteKey
      summary: Revoke a key
      security: [{ AdminToken: [] }]
      responses:
        "200": { description: Revoked. }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "500": { $ref: "#/components/responses/Error" }
  /healthz:
    get:
      summary: Liveness probe
      responses: { "200": { description: ok } }
components:
  securitySchemes:
    ApiKeyQuery:
      type: apiKey
      in: query
      name: api_key
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
    AdminToken:
      type: apiKey
      in: header
      name: X-Admin-Token
      description: Admin token (from deploy/.env). Guards /api/admin/*; per-IP brute-force lockout.
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Source:
      type: string
      enum: [encar, autoscout24, che168, dongchedi, dubizzle, dubicars, mobilede, guazi]
    ListResponse:
      type: object
      properties:
        result: { type: array, items: { $ref: "#/components/schemas/OfferEnvelope" } }
        meta: { $ref: "#/components/schemas/Meta" }
        labels: { type: object, additionalProperties: { type: string }, description: "Present only with lang=ru: maps each characteristic field (mark, model, year, body_type, …) to its Russian display label." }
    Meta:
      type: object
      properties:
        page: { type: integer }
        next_page: { type: integer, description: 0 when there is no next page }
        limit: { type: integer }
        next_cursor: { type: integer, format: int64, description: "Keyset cursor — pass as ?after_change_id= to fetch the next page in O(1). 0 = last page. Use for deep/bulk download instead of page=." }
    OfferEnvelope:
      type: object
      properties:
        id: { type: integer, format: int64, description: Global monotonic change-id (incremental-sync cursor) }
        inner_id: { type: string, description: Id on the source platform }
        change_type: { type: string, enum: [added, changed, removed] }
        created_at: { type: [string, "null"], format: date-time }
        data: { $ref: "#/components/schemas/Data" }
    Data:
      type: object
      description: Normalized car card.
      properties:
        id: { type: integer, format: int64 }
        inner_id: { type: string }
        url: { type: string }
        source: { $ref: "#/components/schemas/Source" }
        mark: { type: string, description: "Make (brand) — always English for every source. Chinese sources (che168, dongchedi) are translated; the original is in extra_prep.make_zh." }
        model: { type: string, description: "Model — never contains hieroglyphs. Chinese sources (che168, dongchedi): Latinized; original in extra_prep.model_zh." }
        generation: { type: string }
        complectation: { type: string, description: Trim }
        year: { type: integer }
        year_month: { type: string }
        body_type: { type: string }
        fuel_type: { type: string, description: "enum: petrol|diesel|hybrid|phev|ev|lpg|..." }
        transmission: { type: string, description: "enum: automatic|manual|cvt|dct|amt" }
        drive_type: { type: string, description: "fwd|rwd|awd" }
        engine_cc: { type: integer }
        power_hp: { type: integer }
        km_age: { type: integer, description: Mileage in km }
        color: { type: string }
        vin: { type: string }
        price: { $ref: "#/components/schemas/Price" }
        country: { type: string }
        region: { type: string }
        city: { type: string, description: "Chinese sources (che168, dongchedi): Latinized (pinyin); original in extra_prep.city_zh. Korean (encar): English; original in extra_prep.city_ko." }
        seller_type: { type: string }
        is_dealer: { type: boolean }
        seller_name: { type: string }
        salon_id: { type: string, description: Dealer / seller id }
        title: { type: string }
        description: { type: string }
        reg_date: { type: [string, "null"], format: date-time, description: Listing publish/registration date }
        images: { type: array, items: { type: string }, description: ALL photo URLs }
        equipment: { type: array, items: { type: string }, description: Equipment / features list }
        condition: { type: object, additionalProperties: true, description: "Accident / owners / emission / inspection" }
        extra_prep: { type: object, additionalProperties: true, description: "All detail specs (doors/seats/full spec table/...). For Chinese sources also holds the original Chinese values: make_zh, model_zh, city_zh." }
        status: { type: string, enum: [active, removed] }
    Price:
      type: object
      description: Source-currency amount plus normalized USD/EUR.
      properties:
        raw: { type: integer, format: int64, description: Amount in raw currency }
        currency: { type: string, description: "ISO code: KRW|CNY|AED|EUR" }
        usd: { type: integer }
        eur: { type: integer }
    Error:
      type: object
      properties:
        error: { type: string }
    CreateKeyRequest:
      type: object
      required: [label]
      properties:
        access_name: { type: string, description: "Unique non-secret handle (URL/billing). Empty → derived from label and made unique.", example: acme-prod }
        label: { type: string, example: Acme production }
        plan: { type: string, enum: [demo, full], default: full }
        rate_limit_day: { type: integer, description: "Daily request quota. 0 = unlimited.", example: 10000 }
        allowed_sources: { type: array, items: { $ref: "#/components/schemas/Source" }, description: "Per-source ACL. Empty/omitted = all sources.", example: [encar, mobilede] }
        expires_days: { type: integer, description: "Lifetime in days. 0 = never.", example: 365 }
    CreateKeyResponse:
      type: object
      description: The `key` field is the plaintext secret, returned only here.
      properties:
        key: { type: string, description: "Plaintext secret — shown ONCE. Store immediately.", example: "live_a1b2c3d4e5f6" }
        id: { type: integer, format: int64 }
        access_name: { type: string }
        prefix: { type: string, description: "Short key prefix (identifies the key in lists).", example: "live_a1b2" }
        label: { type: string }
        plan: { type: string }
        rate_limit_day: { type: integer }
        allowed_sources: { type: [array, "null"], items: { $ref: "#/components/schemas/Source" } }
        expires_at: { type: [string, "null"], format: date-time }
    KeyView:
      type: object
      description: A key as returned by list — never includes the secret.
      properties:
        id: { type: integer, format: int64 }
        access_name: { type: string }
        prefix: { type: string }
        label: { type: string }
        plan: { type: string }
        rate_limit_day: { type: integer }
        used_today: { type: integer }
        used_total: { type: integer, format: int64 }
        allowed_sources: { type: [array, "null"], items: { $ref: "#/components/schemas/Source" } }
        status: { type: string, enum: [active, revoked] }
        expires_at: { type: [string, "null"], format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    PatchKeyRequest:
      type: object
      description: All fields optional — send only what changes.
      properties:
        rate_limit_day: { type: integer }
        allowed_sources: { type: array, items: { $ref: "#/components/schemas/Source" }, description: "[] = clear ACL (all sources)." }
        status: { type: string, enum: [active, revoked] }
        expires_days: { type: integer, description: "<=0 = clear expiry; >0 = now + N days." }
