{
  "openapi": "3.1.0",
  "info": {
    "title": "Just My Recipes API",
    "version": "1.0.0",
    "description": "Public reads, owner-only writes, for a personal recipe collection. Recipes are stored as a schema.org/Recipe subset. Keep this spec in sync with the route handlers under `src/app/api`.\n"
  },
  "servers": [
    {
      "url": "https://justmy.recipes/api",
      "description": "Production"
    },
    {
      "url": "http://localhost:3000/api",
      "description": "Local development"
    }
  ],
  "tags": [
    {
      "name": "recipes"
    }
  ],
  "security": [],
  "paths": {
    "/recipes": {
      "get": {
        "tags": [
          "recipes"
        ],
        "summary": "List recipes",
        "security": [
          {},
          {
            "bearerAuth": []
          }
        ],
        "description": "Public recipes only. With a valid key and `include=drafts`, drafts are included as well (optional auth — the key changes results but is not required).\n",
        "parameters": [
          {
            "name": "tag",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Filter to recipes carrying this tag (case-insensitive)."
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Free-text, case-insensitive substring search over name, description, ingredients, and notes. NOT over keywords/category/cuisine (use `tag`), instructions, or nutrition. Combined with `tag` via AND. Empty/whitespace is a no-op.\n"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          },
          {
            "name": "include",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "drafts"
              ]
            },
            "description": "With a valid key, `drafts` includes non-public recipes."
          }
        ],
        "responses": {
          "200": {
            "description": "A page of recipes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "recipes",
                    "limit",
                    "offset",
                    "count"
                  ],
                  "properties": {
                    "recipes": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/RecipeResponse"
                      }
                    },
                    "limit": {
                      "type": "integer"
                    },
                    "offset": {
                      "type": "integer"
                    },
                    "count": {
                      "type": "integer",
                      "description": "Total number of recipes matching the filters (tag/q/visibility), ignoring limit/offset — i.e. \"N of count\".\n"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "recipes"
        ],
        "summary": "Create a recipe",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RecipeWrite"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. The `Location` header points at the new resource.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecipeResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/recipes/{slug}": {
      "parameters": [
        {
          "name": "slug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "recipes"
        ],
        "summary": "Get one recipe",
        "security": [
          {},
          {
            "bearerAuth": []
          }
        ],
        "description": "Drafts return 404 unless a valid key is provided (optional auth).\n",
        "responses": {
          "200": {
            "description": "The recipe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecipeResponse"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "tags": [
          "recipes"
        ],
        "summary": "Replace a recipe",
        "description": "Full document replace. The slug is immutable; `visibility` is updatable.\n",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RecipeWrite"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated recipe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecipeResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "tags": [
          "recipes"
        ],
        "summary": "Delete a recipe",
        "description": "Hard delete. Requires the primary `RECIPES_API_KEY` — the publish token is rejected here. To take a recipe off the site without deleting, PUT it with `visibility: draft` instead.\n",
        "security": [
          {
            "primaryKey": []
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer <token>`. Accepts EITHER the primary `RECIPES_API_KEY` or the separate `RECIPES_PUBLISH_TOKEN` (the claude.ai Skill's write token). Used for create/update and for reading drafts.\n"
      },
      "primaryKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer <RECIPES_API_KEY>`. The primary key ONLY — the publish token is rejected. Required for hard delete.\n"
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "No such recipe (or a draft requested without a key).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ValidationError": {
        "description": "A 400 error. `error.code` is `validation_error` (the document failed schema validation — `details` maps field path → messages) or `invalid_json` (the request body was not parseable JSON).\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "HowToStep": {
        "type": "object",
        "required": [
          "text"
        ],
        "properties": {
          "@type": {
            "type": "string",
            "const": "HowToStep"
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "description": "Optional short heading for the step."
          },
          "text": {
            "type": "string",
            "minLength": 1
          }
        }
      },
      "Recipe": {
        "type": "object",
        "description": "The accepted schema.org/Recipe subset. Unknown fields are stripped on write, not rejected.\n",
        "required": [
          "name",
          "recipeIngredient"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1
          },
          "description": {
            "type": "string",
            "minLength": 1,
            "description": "Trimmed; non-empty if present."
          },
          "image": {
            "type": "string",
            "format": "uri",
            "description": "Single image URL."
          },
          "recipeYield": {
            "description": "On write, a string or number (number is coerced to string). Always a string in responses.",
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "prepTime": {
            "type": "string",
            "description": "ISO 8601 duration, e.g. PT10M."
          },
          "cookTime": {
            "type": "string",
            "description": "ISO 8601 duration."
          },
          "totalTime": {
            "type": "string",
            "description": "ISO 8601 duration."
          },
          "recipeIngredient": {
            "type": "array",
            "minItems": 1,
            "items": {
              "type": "string",
              "minLength": 1
            }
          },
          "recipeInstructions": {
            "description": "Accepts an array of strings or HowToStep objects; normalized to HowToStep[] on write.\n",
            "type": "array",
            "items": {
              "oneOf": [
                {
                  "type": "string"
                },
                {
                  "$ref": "#/components/schemas/HowToStep"
                }
              ]
            }
          },
          "recipeCategory": {
            "description": "String, comma-separated string, or array; normalized to array.",
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            ]
          },
          "recipeCuisine": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            ]
          },
          "keywords": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            ]
          },
          "notes": {
            "type": "string",
            "minLength": 1,
            "description": "Freeform cook's notes; trimmed, non-empty if present."
          },
          "nutrition": {
            "type": "object",
            "description": "schema.org NutritionInformation, per serving. STORED and returned in this API as plain numbers (grams for the mass macros, kcal for calories) so the data is computable. On write, values MUST be numbers — unit-strings like \"22 g\" are rejected. The public page's JSON-LD renders these as schema.org unit strings (\"31 g\", \"420 calories\"); this API body keeps numbers. Everything optional; omit the whole object if unknown. Unknown sub-keys are stripped on write (not rejected), like unknown top-level fields.\n",
            "properties": {
              "calories": {
                "type": "number",
                "minimum": 0,
                "description": "kcal per serving."
              },
              "proteinContent": {
                "type": "number",
                "minimum": 0,
                "description": "grams per serving."
              },
              "fatContent": {
                "type": "number",
                "minimum": 0,
                "description": "grams per serving."
              },
              "carbohydrateContent": {
                "type": "number",
                "minimum": 0,
                "description": "grams per serving."
              },
              "fiberContent": {
                "type": "number",
                "minimum": 0,
                "description": "grams per serving."
              },
              "sugarContent": {
                "type": "number",
                "minimum": 0,
                "description": "grams per serving."
              },
              "sodiumContent": {
                "type": "number",
                "minimum": 0,
                "description": "grams per serving."
              },
              "saturatedFatContent": {
                "type": "number",
                "minimum": 0,
                "description": "grams per serving."
              }
            }
          }
        }
      },
      "RecipeWrite": {
        "description": "A recipe document plus an optional visibility.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Recipe"
          },
          {
            "type": "object",
            "properties": {
              "visibility": {
                "type": "string",
                "enum": [
                  "public",
                  "draft"
                ],
                "default": "draft",
                "description": "Default applies to CREATE (POST) only — omitting it there creates a draft. On PUT (replace), omitting it leaves the current visibility unchanged.\n"
              }
            }
          }
        ]
      },
      "RecipeResponse": {
        "description": "The stored document (normalized) plus server-managed fields. `recipeInstructions` is always HowToStep[]; `tags` is the derived, lowercased union of category + cuisine + keywords.\n",
        "allOf": [
          {
            "$ref": "#/components/schemas/Recipe"
          },
          {
            "type": "object",
            "required": [
              "slug",
              "visibility",
              "tags",
              "createdAt",
              "updatedAt",
              "recipeInstructions"
            ],
            "properties": {
              "recipeInstructions": {
                "type": "array",
                "description": "Always present (defaults to []) and always HowToStep[] in responses.",
                "items": {
                  "$ref": "#/components/schemas/HowToStep"
                }
              },
              "slug": {
                "type": "string"
              },
              "visibility": {
                "type": "string",
                "enum": [
                  "public",
                  "draft"
                ]
              },
              "tags": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "createdAt": {
                "type": "string",
                "format": "date-time"
              },
              "updatedAt": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        ]
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string"
              },
              "message": {
                "type": "string"
              },
              "details": {
                "type": "object",
                "additionalProperties": true,
                "description": "For validation errors, a map of field path → messages.\n"
              }
            }
          }
        }
      }
    }
  }
}
