openapi: 3.1.0
info:
  title: Smart Kitchen API
  version: "1.0.0"
  description: |
    Recipes in the user's Smart Kitchen cloud — the same library the Android app syncs.
    Sign in with Firebase Authentication (Google) and send the Firebase ID token as
    `Authorization: Bearer <token>`. Cloud sync is free: up to 1000 recipes per account
    and up to 100 new recipes per hour; one JPEG photo (≤ 1 MB) per recipe.
servers:
  - url: https://api.krassan.com
    description: Production
  - url: https://smartkitchen-574d8-api.web.app
    description: Production (Firebase Hosting default domain)
  - url: https://me-west1-smartkitchen-574d8.cloudfunctions.net/api
    description: Direct function URL
security:
  - firebase: []
paths:
  /v1/import:
    post:
      summary: Read a recipe from a web page; returns a draft, saves nothing
      description: Best effort — schema.org Recipe first, then the page structure (ingredient/step lists, tables, headings, article text). Fails only when the page cannot be fetched or has nothing usable. At most 30 imports per hour per account. `photo` is the page's recipe image, if any, for the client to resize and upload.
      requestBody:
        required: true
        content: {application/json: {schema: {type: object, required: [url], properties: {url: {type: string, format: uri}}}}}
      responses:
        "200":
          description: Draft
          content:
            application/json:
              schema:
                type: object
                properties:
                  recipe: {$ref: "#/components/schemas/RecipeInput"}
                  partial: {type: boolean, description: "true when the page had no complete schema.org recipe and parts were guessed or are missing"}
                  missing: {type: array, items: {type: string, enum: [ingredients, steps, photo]}}
                  photo: {type: [object, "null"], properties: {contentType: {type: string}, base64: {type: string}}}
        "400": {$ref: "#/components/responses/Error"}
        "422": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
  /v1/me:
    get:
      summary: Cloud status of the signed-in account and what is stored
      responses:
        "200": {description: OK, content: {application/json: {schema: {$ref: "#/components/schemas/Account"}}}}
        "401": {$ref: "#/components/responses/Error"}
  /v1/me/consent:
    post:
      summary: Accept the cloud terms (required once before saving recipes)
      requestBody:
        required: true
        content: {application/json: {schema: {type: object, required: [termsVersion], properties: {termsVersion: {const: 1}}}}}
      responses:
        "200": {description: Accepted, content: {application/json: {schema: {$ref: "#/components/schemas/Account"}}}}
        "400": {$ref: "#/components/responses/Error"}
  /v1/recipes:
    get:
      summary: All active recipes, plus ids deleted on any device
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [recipes, deletedIds]
                properties:
                  recipes: {type: array, items: {$ref: "#/components/schemas/Recipe"}}
                  deletedIds: {type: array, items: {type: string}}
  /v1/recipes/{id}:
    parameters: [{$ref: "#/components/parameters/RecipeId"}]
    get:
      summary: One recipe
      responses:
        "200": {description: OK, content: {application/json: {schema: {$ref: "#/components/schemas/Recipe"}}}}
        "404": {$ref: "#/components/responses/Error"}
    put:
      summary: Create or replace a recipe (client chooses the id, e.g. a UUID)
      description: |
        `updatedAt` (ms since epoch) orders edits across devices; if the cloud already has a
        newer version the request fails with 409 and nothing is overwritten. If omitted, the
        server time is used. The photo is kept.
      requestBody:
        required: true
        content: {application/json: {schema: {$ref: "#/components/schemas/RecipeInput"}}}
      responses:
        "200": {description: Saved, content: {application/json: {schema: {$ref: "#/components/schemas/Recipe"}}}}
        "400": {$ref: "#/components/responses/Error"}
        "409": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
    delete:
      summary: Delete a recipe on all devices
      responses:
        "204": {description: Deleted}
        "404": {$ref: "#/components/responses/Error"}
  /v1/recipes/{id}/photo:
    parameters: [{$ref: "#/components/parameters/RecipeId"}]
    get:
      summary: The recipe photo
      responses:
        "200": {description: JPEG, content: {image/jpeg: {schema: {type: string, format: binary}}}}
        "404": {$ref: "#/components/responses/Error"}
    put:
      summary: Set or replace the recipe photo (JPEG, ≤ 1 MB; resize to ≤ 1280 px before upload)
      requestBody:
        required: true
        content: {image/jpeg: {schema: {type: string, format: binary}}}
      responses:
        "204": {description: Saved}
        "413": {$ref: "#/components/responses/Error"}
        "415": {$ref: "#/components/responses/Error"}
    delete:
      summary: Remove the recipe photo
      responses:
        "204": {description: Removed}
components:
  securitySchemes:
    firebase: {type: http, scheme: bearer, bearerFormat: Firebase ID token}
  parameters:
    RecipeId:
      name: id
      in: path
      required: true
      schema: {type: string, pattern: "^[A-Za-z0-9_-]{1,100}$"}
  responses:
    Error:
      description: |
        Error. `code` examples: sign-in-required, invalid-token, consent-required,
        invalid-recipe, recipe-not-found, newer-cloud-recipe, recipe-limit, hourly-limit,
        daily-limit, photo-too-large, jpeg-required, cloud-busy.
      content:
        application/json:
          schema: {type: object, properties: {error: {type: object, properties: {code: {type: string}}}}}
  schemas:
    Account:
      type: object
      properties:
        cloud:
          type: object
          properties:
            state: {type: string, enum: [consent, ready, paid, paused, deleting, expired]}
            consentRequired: {type: boolean}
            writable: {type: boolean}
            termsVersion: {type: integer}
        usage:
          type: [object, "null"]
          properties:
            recipes: {type: integer}
            photos: {type: integer}
    Ingredient:
      type: object
      required: [name]
      properties:
        position: {type: integer, minimum: 0}
        name: {type: string, maxLength: 500}
        amount: {type: [string, number, "null"]}
        note: {type: [string, "null"], maxLength: 1000}
    RecipeInput:
      type: object
      required: [title]
      properties:
        title: {type: string, minLength: 1, maxLength: 500}
        servings: {type: [integer, "null"], minimum: 1, maximum: 1000}
        isFavorite: {type: boolean}
        tags:
          type: array
          maxItems: 50
          description: Category ids, e.g. breakfast, soups, desserts (see the app's 15 categories)
          items: {type: string}
        ingredients: {type: array, maxItems: 300, items: {$ref: "#/components/schemas/Ingredient"}}
        steps: {type: array, maxItems: 200, items: {type: string}}
        sourceUrl: {type: [string, "null"]}
        sourceAuthor: {type: [string, "null"]}
        sourceYield: {type: [string, "null"]}
        updatedAt: {type: integer, description: ms since epoch}
    Recipe:
      allOf:
        - $ref: "#/components/schemas/RecipeInput"
        - type: object
          required: [id, updatedAt, hasPhoto]
          properties:
            id: {type: string}
            hasPhoto: {type: boolean}
