openapi: 3.0.3
info:
  title: Komparable API
  description: >-
    Persistent product comparisons with customizable attributes, weights, and
    scores. Create a comparison, add products with per-attribute scores, and
    get a weighted ranking to help users make better purchases.
  version: 1.0.0
servers:
  - url: https://komparable.com
paths:
  /api/comparisons:
    post:
      operationId: createComparison
      summary: Create a new product comparison
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ComparisonInput"
      responses:
        "201":
          description: The created comparison. Tell the user the id so they can retrieve it later.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comparison"
  /api/comparisons/{id}:
    get:
      operationId: getComparison
      summary: Retrieve a comparison by id, including the current product ranking
      parameters:
        - $ref: "#/components/parameters/ComparisonId"
      responses:
        "200":
          description: The comparison with ranked products.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comparison"
        "404":
          description: Comparison not found.
    patch:
      operationId: updateComparison
      summary: Update a comparison's title, description, or attributes
      parameters:
        - $ref: "#/components/parameters/ComparisonId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ComparisonPatch"
      responses:
        "200":
          description: The updated comparison with recomputed ranking.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comparison"
        "404":
          description: Comparison not found.
    delete:
      operationId: deleteComparison
      summary: Delete a comparison permanently
      parameters:
        - $ref: "#/components/parameters/ComparisonId"
      responses:
        "200":
          description: Deletion confirmation.
        "404":
          description: Comparison not found.
  /api/comparisons/{id}/products:
    post:
      operationId: addProduct
      summary: Add a product to a comparison with per-attribute scores
      parameters:
        - $ref: "#/components/parameters/ComparisonId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductInput"
      responses:
        "201":
          description: The comparison including the new product and updated ranking.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comparison"
        "400":
          description: Invalid input, unknown score attribute, or the 50-product limit was reached.
        "404":
          description: Comparison not found.
  /api/comparisons/{id}/products/{productId}:
    patch:
      operationId: updateProduct
      summary: Update a product's details or scores (scores are merged)
      parameters:
        - $ref: "#/components/parameters/ComparisonId"
        - $ref: "#/components/parameters/ProductId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductPatch"
      responses:
        "200":
          description: The comparison with the updated product and recomputed ranking.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comparison"
        "404":
          description: Comparison or product not found.
    delete:
      operationId: removeProduct
      summary: Remove a product from a comparison
      parameters:
        - $ref: "#/components/parameters/ComparisonId"
        - $ref: "#/components/parameters/ProductId"
      responses:
        "200":
          description: The comparison without the removed product.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comparison"
        "404":
          description: Comparison or product not found.
components:
  parameters:
    ComparisonId:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: The comparison id returned at creation time.
    ProductId:
      name: productId
      in: path
      required: true
      schema:
        type: string
      description: The product id as returned inside the comparison.
  schemas:
    Attribute:
      type: object
      required: [name]
      properties:
        id:
          type: string
          description: >-
            Stable attribute id, assigned by the server. When updating a
            comparison's attributes, include the id (or keep the exact name) to
            preserve existing product scores across renames; attributes omitted
            from the list are removed along with their scores.
        name:
          type: string
          description: Attribute name, e.g. "Battery life". Must be unique within a comparison; usable as the key in product scores.
        weight:
          type: number
          minimum: 0
          maximum: 10
          default: 5
          description: Importance of this attribute from 0 (ignore) to 10 (critical).
        description:
          type: string
          description: Optional explanation of what this attribute measures.
    ComparisonInput:
      type: object
      required: [title, attributes]
      properties:
        title:
          type: string
          description: Title of the comparison, e.g. "Noise-cancelling headphones".
        description:
          type: string
        attributes:
          type: array
          items:
            $ref: "#/components/schemas/Attribute"
    ComparisonPatch:
      type: object
      properties:
        title:
          type: string
        description:
          type: string
        attributes:
          type: array
          description: Replaces the full attribute list when provided.
          items:
            $ref: "#/components/schemas/Attribute"
    ProductInput:
      type: object
      required: [name]
      properties:
        name:
          type: string
        url:
          type: string
          format: uri
        price:
          type: number
        currency:
          type: string
          description: ISO currency code, e.g. "USD".
        notes:
          type: string
        scores:
          type: object
          additionalProperties:
            type: number
            minimum: 0
            maximum: 10
          description: >-
            Map of attribute name (case-insensitive) or attribute id to a score
            from 0 to 10. Unknown keys are rejected with a 400 listing the
            valid attributes. Score against absolute anchors (0 = unacceptable,
            10 = best imaginable), not relative to the current product set.
    ProductPatch:
      type: object
      description: Partial product update; all fields optional, scores are merged by attribute name.
      properties:
        name:
          type: string
        url:
          type: string
          format: uri
        price:
          type: number
        currency:
          type: string
        notes:
          type: string
        scores:
          type: object
          additionalProperties:
            type: number
            minimum: 0
            maximum: 10
    Product:
      allOf:
        - $ref: "#/components/schemas/ProductInput"
        - type: object
          properties:
            id:
              type: string
    RankedProduct:
      allOf:
        - $ref: "#/components/schemas/Product"
        - type: object
          properties:
            totalScore:
              type: number
              nullable: true
              description: Weighted average of known positive-weight scores (0–10); null when none are available.
            rank:
              type: integer
              nullable: true
              description: Shared rank for ties; null without a score. Rankings with missing evidence are provisional.
            scoreCoverage:
              type: number
              minimum: 0
              maximum: 1
              description: Fraction of positive attribute weight backed by known scores.
            provisional:
              type: boolean
    Comparison:
      type: object
      properties:
        rankingStatus:
          type: string
          enum: [complete, provisional, no_weights]
        id:
          type: string
        rev:
          type: integer
          description: Revision counter, incremented on every change.
        title:
          type: string
        description:
          type: string
        attributes:
          type: array
          items:
            $ref: "#/components/schemas/Attribute"
        products:
          type: array
          items:
            $ref: "#/components/schemas/Product"
        ranking:
          type: array
          description: Products sorted best-first by weighted score.
          items:
            $ref: "#/components/schemas/RankedProduct"
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
