openapi: 3.1.0
info:
  title: Biblon Catalog API
  version: 1.0.0
  description: |
    Book catalog API for ValueBooks' Honya SaaS platform.
    Biblon is the authoritative source for book and product master data —
    bibliographic metadata, pricing, classification, and cover images.

    ## Authentication
    All `/api/*` endpoints require a bearer token:

        Authorization: Bearer biblon_<env>_<random>

    Keys are issued per consumer by the ValueBooks platform team. Each
    service stores its own key in its own secret store; there is no
    shared password and no rendered local spec file.

    ### Dev environment

    Shared key for the `biblon-dev.valuebooks.jp` server (dev only — do
    not reuse in prod):

        biblon_dev_4CHx0rK5IpsyVF8oM1QTShTwgELcY--d

    ## Catalog ID
    Each edition has a `catalogId` — a `VC` prefix followed by 8 lowercase
    alphanumeric characters (e.g., `VC4f7kx2q9`). This is the stable domain
    identifier for cross-service references.
  contact:
    name: ValueBooks Engineering
  license:
    name: Proprietary

servers:
  - url: http://localhost:8001
    description: Local development
  - url: https://biblon-dev.valuebooks.jp
    description: Dev environment

security:
  - bearerAuth: []

paths:
  /health:
    get:
      operationId: getHealth
      summary: Liveness check
      security: []
      tags: [Health]
      responses:
        "200":
          description: API is alive
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    const: ok
              example:
                status: ok

  /api/books/search:
    get:
      operationId: searchBooks
      summary: Full-text search with filters
      description: |
        Japanese full-text search via OpenSearch with kuromoji tokenizer.
        Searches across title (3x boost), author (2x boost), publisher,
        description, and ISBN. Supports filtering by author, publisher, and date range, with pagination.
        At least one of `q` or a filter parameter is required.

        When OpenSearch returns no results for a query-only search (no filters),
        the API falls back to external providers: OpenBD (ISBN only) → Google Books
        (ISBN and free-text). Results from external providers have `source` set to
        `openbd` or `google_books` and an empty `catalogId`.
      tags: [Books]
      parameters:
        - name: q
          in: query
          required: false
          description: Free-text search query (Japanese or English)
          schema:
            type: string
          example: 夏目漱石
        - name: publisher
          in: query
          required: false
          description: Filter by publisher (full-text match via kuromoji)
          schema:
            type: string
          example: 筑摩書房
        - name: author
          in: query
          required: false
          description: Filter by author (full-text match via kuromoji)
          schema:
            type: string
          example: 上野千鶴子
        - name: published_from
          in: query
          required: false
          description: Filter by publication date (inclusive start, ISO 8601)
          schema:
            type: string
            format: date
          example: "2025-01-01"
        - name: published_to
          in: query
          required: false
          description: Filter by publication date (inclusive end, ISO 8601)
          schema:
            type: string
            format: date
          example: "2025-12-31"
        - name: offset
          in: query
          required: false
          description: Pagination offset
          schema:
            type: integer
            minimum: 0
            default: 0
          example: 0
        - name: limit
          in: query
          required: false
          description: Results per page (max 100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          example: 20
      responses:
        "200":
          description: Paginated search results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchResponse"
              example:
                results:
                  - catalogId: VC4f7kx2q9
                    isbn: "9784003101018"
                    title: 吾輩は猫である
                    author: 夏目 漱石
                    publisher: 岩波書店
                    year: 2004
                    pages: 592
                    price: 660
                    coverUrl: https://cdn.valuebooks.jp/covers/9784003101018.jpg
                    description: 中学教師珍野苦沙弥先生の書斎に集まる...
                    cCode: C0193
                    source: jpro
                    score: 12.45
                total: 342
                offset: 0
                limit: 20
        "400":
          $ref: "#/components/responses/BadRequest"
        "503":
          description: Search service unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/books:
    get:
      operationId: getBookByIdentifier
      summary: Lookup by ISBN or catalog ID
      description: |
        Find a book by ISBN-13 or catalog ID. Provide exactly one of `isbn` or `catalog`.
        ISBN lookup checks PostgreSQL first, then falls back to OpenBD and Google Books.
        Catalog ID lookup checks PostgreSQL only.
      tags: [Books]
      parameters:
        - name: isbn
          in: query
          required: false
          description: ISBN-13 (e.g., 9784003101018)
          schema:
            type: string
            pattern: "^[0-9]{13}$"
          example: "9784003101018"
        - name: catalog
          in: query
          required: false
          description: Catalog ID (VC + 8 alphanumeric chars, e.g., VC4f7kx2q9)
          schema:
            type: string
            pattern: "^VC[a-z0-9]{8}$"
          example: "VC4f7kx2q9"
      responses:
        "200":
          description: Book found (Book from catalog, or SearchResult from external fallback)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/Book"
                  - $ref: "#/components/schemas/SearchResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"

    post:
      operationId: upsertBook
      summary: Upsert a book
      description: |
        Create or update a book. Deduplicates by ISBN (if provided and valid),
        then by title + author. Returns 201 if created, 200 if existing found.
      tags: [Books]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateBookInput"
            example:
              isbn: "9784003101018"
              title: 吾輩は猫である
              author: 夏目 漱石
              publisher: 岩波書店
              year: 2004
              pages: 592
              price: 660
      responses:
        "200":
          description: Existing book found (deduplicated)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Book"
        "201":
          description: Book created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Book"
        "400":
          $ref: "#/components/responses/BadRequest"

  /api/books/batch:
    post:
      operationId: getBooksBatch
      summary: Batch get by ISBNs
      description: |
        Retrieve multiple books by their ISBNs in a single request.
      tags: [Books]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [isbns]
              properties:
                isbns:
                  type: array
                  items:
                    type: string
                    pattern: "^97[89]\\d{10}$"
                  description: List of ISBN-13 values
            example:
              isbns:
                - "9784101010014"
                - "9784101010021"
      responses:
        "200":
          description: Books found (missing ISBNs are silently omitted)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Book"
        "400":
          $ref: "#/components/responses/BadRequest"

  /api/books/{id}:
    get:
      operationId: getBookById
      summary: Lookup by UUID
      description: |
        Find a book by its internal PostgreSQL UUID.
      tags: [Books]
      parameters:
        - name: id
          in: path
          required: true
          description: Internal UUID
          schema:
            type: string
            format: uuid
          example: 550e8400-e29b-41d4-a716-446655440000
      responses:
        "200":
          description: Book found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Book"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/isbn/validate:
    get:
      operationId: validateIsbn
      summary: Validate an ISBN
      description: |
        Validate an ISBN string and detect its format (ISBN-10 or ISBN-13).
        Normalizes the input by removing hyphens and spaces.
      tags: [ISBN]
      parameters:
        - name: isbn
          in: query
          required: true
          description: ISBN to validate (hyphens allowed)
          schema:
            type: string
          example: "978-4-00-310101-8"
      responses:
        "200":
          description: Validation result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IsbnValidationResult"
              examples:
                valid:
                  summary: Valid ISBN-13
                  value:
                    valid: true
                    format: isbn13
                    normalized: "9784003101018"
                invalid:
                  summary: Invalid ISBN
                  value:
                    valid: false
                    format: null
                    normalized: null
        "400":
          $ref: "#/components/responses/BadRequest"

  /api/isbn/convert:
    get:
      operationId: convertIsbn10to13
      summary: Convert ISBN-10 to ISBN-13
      description: |
        Convert a valid ISBN-10 to its ISBN-13 equivalent.
      tags: [ISBN]
      parameters:
        - name: isbn10
          in: query
          required: true
          description: ISBN-10 to convert
          schema:
            type: string
            pattern: "^[0-9]{9}[0-9X]$"
          example: "4003101014"
      responses:
        "200":
          description: Converted ISBN
          content:
            application/json:
              schema:
                type: object
                required: [isbn13]
                properties:
                  isbn13:
                    type: string
                    description: The ISBN-13 equivalent
              example:
                isbn13: "9784003101018"
        "400":
          $ref: "#/components/responses/BadRequest"

  /api/admin/stats:
    get:
      operationId: getAdminStats
      summary: Catalog statistics
      description: |
        Returns total book count, per-source breakdown, and OpenSearch index stats.
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: Statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  totalBooks:
                    type: integer
                  booksBySource:
                    type: array
                    items:
                      type: object
                      properties:
                        source:
                          type: string
                        count:
                          type: integer
                        latest:
                          type: [string, "null"]
                          format: date-time
                  opensearch:
                    type: object
                    properties:
                      index:
                        type: string
                      docCount:
                        type: integer
                      storeSize:
                        type: string

  /api/admin/etl-runs:
    get:
      operationId: getEtlRuns
      summary: Recent ETL runs
      description: |
        Returns the 20 most recent ETL pipeline runs.
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: ETL run history
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      format: uuid
                    source:
                      type: string
                    status:
                      type: string
                    startedAt:
                      type: string
                      format: date-time
                    finishedAt:
                      type: [string, "null"]
                      format: date-time
                    recordsProcessed:
                      type: integer
                    errorMessage:
                      type: [string, "null"]

  /api/admin/health:
    get:
      operationId: getAdminHealth
      summary: Deep health check
      description: |
        Checks connectivity to PostgreSQL, OpenSearch, and API.
        Returns 503 if any dependency is disconnected.
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: All dependencies connected
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: object
                  properties:
                    status:
                      type: string
                      enum: [connected, disconnected]
                    responseMs:
                      type: integer
              example:
                postgresql:
                  status: connected
                  responseMs: 3
                opensearch:
                  status: connected
                  responseMs: 12
                api:
                  status: connected
                  responseMs: 0
        "503":
          description: One or more dependencies disconnected

  /api/admin/kpi/coverage:
    get:
      operationId: getCoverageKpi
      summary: Catalog coverage KPI
      description: |
        Computes catalog coverage breadth (edition counts, coverage rate vs NDL)
        and depth (metadata completeness scores, field fill rates).
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: Coverage KPI metrics
          content:
            application/json:
              schema:
                type: object
                properties:
                  breadth:
                    type: object
                    properties:
                      totalEditions:
                        type: integer
                      bySource:
                        type: object
                        properties:
                          jpro:
                            type: integer
                          nippan:
                            type: integer
                          both:
                            type: integer
                      newThisMonth:
                        type: integer
                      newLastMonth:
                        type: integer
                      coverageRate:
                        type: object
                        properties:
                          ndlTotal:
                            type: integer
                          overallPct:
                            type: number
                          jproContributionPct:
                            type: number
                          nippanContributionPct:
                            type: number
                          gap:
                            type: integer
                  depth:
                    type: object
                    properties:
                      overallScore:
                        type: number
                      scoreBySource:
                        type: object
                        properties:
                          jpro:
                            type: number
                          nippan:
                            type: number
                      fieldFillRates:
                        type: object
                        additionalProperties:
                          type: number
                  computedAt:
                    type: string
                    format: date-time

  /api/admin/api-clients:
    post:
      summary: Mint a new per-consumer API key
      description: |
        Creates a new `api_clients` row and returns the plaintext key ONCE.
        The plaintext is never stored or retrievable later.
      operationId: mintApiClient
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 64
                  pattern: "^[a-zA-Z0-9._-]+$"
                  example: dev-store
                isAdmin:
                  type: boolean
                  default: false
      responses:
        "201":
          description: API key minted; plaintext shown only in this response.
          content:
            application/json:
              schema:
                type: object
                required: [id, name, keyPrefix, plaintextKey, isAdmin, createdBy, createdAt]
                properties:
                  id:
                    type: string
                    format: uuid
                  name:
                    type: string
                  keyPrefix:
                    type: string
                    example: biblon_dev_ab12
                  plaintextKey:
                    type: string
                    description: Bearer token; never returned again.
                  isAdmin:
                    type: boolean
                  createdBy:
                    type: string
                    description: Server-derived from the minting client's name.
                  createdAt:
                    type: string
                    format: date-time
        "400":
          description: Validation error
        "401":
          description: Missing or invalid bearer token
        "403":
          description: Bearer token is not an admin key
    get:
      summary: List active API keys
      description: Returns non-revoked clients. Never includes hashes or plaintext.
      operationId: listApiClients
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      responses:
        "200":
          description: Active clients.
          content:
            application/json:
              schema:
                type: object
                required: [clients]
                properties:
                  clients:
                    type: array
                    items:
                      type: object
                      required: [id, name, keyPrefix, createdBy, createdAt, lastUsedAt, isAdmin]
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        keyPrefix: { type: string, example: biblon_dev_ab12 }
                        createdBy: { type: string }
                        createdAt: { type: string, format: date-time }
                        lastUsedAt: { type: [string, "null"], format: date-time }
                        isAdmin: { type: boolean }
        "401": { description: Missing or invalid credentials }
        "403": { description: Caller is not an admin }

  /api/admin/api-clients/{prefix}:
    delete:
      summary: Revoke an active API key
      operationId: revokeApiClient
      tags: [Admin]
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: prefix
          in: path
          required: true
          schema:
            type: string
            pattern: "^biblon_(dev|prod)_[A-Za-z0-9_-]{4,5}$"
          example: biblon_dev_ab12
      responses:
        "204": { description: Revoked. }
        "400": { description: Malformed prefix. }
        "401": { description: Missing or invalid credentials. }
        "403": { description: Caller is not an admin. }
        "404": { description: No active key matches the prefix. }

  /api/auth/login:
    post:
      summary: Log in with email + password
      operationId: authLogin
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 12 }
      responses:
        "200":
          description: Session cookie set
          headers:
            Set-Cookie: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      email: { type: string }
                      name: { type: string }
        "401": { description: Invalid credentials }
        "429": { description: Too many attempts — wait 15 minutes }
  /api/auth/logout:
    post:
      summary: Revoke current session and clear cookie
      operationId: authLogout
      tags: [Auth]
      security:
        - cookieAuth: []
      responses:
        "204": { description: Logged out }
  /api/auth/me:
    get:
      summary: Return current session actor
      operationId: authMe
      tags: [Auth]
      security:
        - cookieAuth: []
      responses:
        "200":
          description: Actor info
          content:
            application/json:
              schema:
                type: object
                properties:
                  kind: { type: string, enum: [user] }
                  id: { type: string, format: uuid }
                  email: { type: string }
                  name: { type: string }
        "401": { description: Not signed in }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: biblon_<env>_<random>
      description: |
        Per-consumer API key issued by the ValueBooks platform team.
        Send as `Authorization: Bearer biblon_<env>_<...>`. Contact
        platform@valuebooks to obtain a key.
    cookieAuth:
      type: apiKey
      in: cookie
      name: biblon_session
      description: |
        Session cookie issued by POST /api/auth/login. HttpOnly, Secure,
        SameSite=Lax. Used by the admin UI. Not used by service consumers.

  schemas:
    Book:
      type: object
      description: A book edition in the catalog
      required:
        - id
        - catalogId
        - isbn
        - title
        - author
        - publisher
        - coverUrl
        - description
        - cCode
        - language
        - source
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          format: uuid
          description: Internal PostgreSQL UUID
        catalogId:
          type: string
          pattern: "^VC[a-z0-9]{8}$"
          description: Domain identifier (VC + 8 alphanumeric chars)
          examples: [VC4f7kx2q9]
        isbn:
          type: string
          description: ISBN-13 (empty string if no ISBN)
        title:
          type: string
        author:
          type: string
        publisher:
          type: string
        year:
          type: [integer, "null"]
          description: Publication year
        publishedDate:
          type: [string, "null"]
          format: date
          description: Publication date (YYYY-MM-DD)
        pages:
          type: [integer, "null"]
          description: Page count
        coverUrl:
          type: string
          description: CDN URL for cover image
        description:
          type: string
          description: Book description or summary
        cCode:
          type: string
          description: Japanese C-code classification
        price:
          type: [integer, "null"]
          description: Price in JPY
        language:
          type: string
          description: ISO 639-2 language code (default "jpn")
        source:
          type: string
          enum: [jpro, nippan, api]
          description: Data origin
        amazonRank:
          type: [integer, "null"]
          description: Amazon.co.jp sales rank (lower = better seller). Null if not yet ranked.
        amazonRankCategory:
          type: [string, "null"]
          description: |
            Raw upstream category value for the rank. Typically
            "book_display_on_website" or a numeric Amazon browse-node id
            (e.g. "2278488051" for manga). Null if not ranked.
        amazonRankFetchedAt:
          type: [string, "null"]
          format: date-time
          description: |
            Timestamp when the rank was observed from Amazon by the upstream
            marketing-ETL pipeline (source column `prev_dtm`). Not the API's
            write time — see `updatedAt` for that. Null if not ranked.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    SearchResult:
      type: object
      description: A search hit (from OpenSearch or external fallback providers)
      required:
        - catalogId
        - isbn
        - title
        - author
        - publisher
        - coverUrl
        - description
        - cCode
        - source
        - score
      properties:
        catalogId:
          type: string
          description: Domain identifier (empty string for external fallback results)
        isbn:
          type: string
        title:
          type: string
        author:
          type: string
        publisher:
          type: string
        year:
          type: [integer, "null"]
        publishedDate:
          type: [string, "null"]
          format: date
          description: Publication date (YYYY-MM-DD)
        pages:
          type: [integer, "null"]
        price:
          type: [integer, "null"]
          description: Price in JPY
        coverUrl:
          type: string
        description:
          type: string
        cCode:
          type: string
        source:
          type: string
          enum: [jpro, nippan, api, openbd, google_books]
          description: Data origin (openbd/google_books for external fallback results)
        score:
          type: number
          format: float
          description: BM25 relevance score (0 for external fallback results)
        amazonRank:
          type: [integer, "null"]
          description: Amazon.co.jp sales rank (lower = better seller). Null if not yet ranked.
        amazonRankCategory:
          type: [string, "null"]
          description: |
            Raw upstream category value for the rank. Typically
            "book_display_on_website" or a numeric Amazon browse-node id
            (e.g. "2278488051" for manga). Null if not ranked.
        amazonRankFetchedAt:
          type: [string, "null"]
          format: date-time
          description: |
            Timestamp when the rank was observed from Amazon by the upstream
            marketing-ETL pipeline (source column `prev_dtm`). Not the API's
            write time — see `updatedAt` for that. Null if not ranked.

    SearchResponse:
      type: object
      description: Paginated search results
      required:
        - results
        - total
        - offset
        - limit
      properties:
        results:
          type: array
          items:
            $ref: "#/components/schemas/SearchResult"
        total:
          type: integer
          description: Total number of matching results
        offset:
          type: integer
          description: Current pagination offset
        limit:
          type: integer
          description: Results per page

    CreateBookInput:
      type: object
      description: Input for creating or upserting a book
      required: [title, author]
      properties:
        isbn:
          type: string
          pattern: "^[0-9]{13}$"
          description: ISBN-13 (validated with mod-10 checksum)
        title:
          type: string
        author:
          type: string
        publisher:
          type: string
        year:
          type: integer
        pages:
          type: integer
        coverUrl:
          type: string
        description:
          type: string
        cCode:
          type: string
        price:
          type: integer
          description: Price in JPY
        language:
          type: string
          description: ISO 639-2 language code (defaults to "jpn")

    IsbnValidationResult:
      type: object
      required: [valid]
      properties:
        valid:
          type: boolean
        format:
          type: [string, "null"]
          enum: [isbn10, isbn13, null]
        normalized:
          type: [string, "null"]
          description: Normalized ISBN (hyphens removed) if valid

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error message

  responses:
    BadRequest:
      description: Invalid request parameters
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "q query parameter required"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "Not found"

tags:
  - name: Health
    description: Liveness and readiness checks
  - name: Books
    description: Book catalog CRUD and search
  - name: ISBN
    description: ISBN validation and conversion utilities
  - name: Admin
    description: Admin operations and KPIs
  - name: Auth
    description: Admin user authentication (session cookie)
