use crate::web::templates::{ApiNote, ApiPreamble, ApiSection, EndpointDoc, ParamDoc};
fn note(label: &str, text: &str) -> ApiNote {
ApiNote {
label: label.to_string(),
text: text.to_string(),
}
}
pub fn preamble() -> ApiPreamble {
ApiPreamble {
intro: "HTTP endpoints for building integrations against this CookCLI server.".to_string(),
notes: vec![
note(
"Authentication",
"Off unless the server has a users file (`cook server user add <name>`). \
Without one, anyone who can reach the server can read and modify your \
recipes — think twice before using `--host` on an untrusted network. With \
one, reads stay open, and every request that changes something needs the \
session cookie that signing in sets: `curl -c jar -d username=<name> \
--data-urlencode password=<password> <server>/login`, then pass `-b jar` \
to later requests. `GET /api/ws/lsp` and `/api/sync/*` need it too.",
),
note(
"CORS",
"Off unless the server was started with `--cors-origin`: no other origin can \
read its answers. `--cors-origin '*'` lets any origin `GET`; a cross-origin \
request that would modify recipes is still refused with `403` unless it comes \
from an origin named explicitly. Requests with no `Origin` header — `curl` and \
other non-browser clients — are unaffected. `content-type` is always an \
allowed request header. Every request, from a browser or not, has to be sent \
to `localhost`, an IP address, or the host of a `--cors-origin`; any other \
`Host` is refused with `403`.",
),
note(
"Request size limit",
"1 MB, except a title or step picture upload (`PUT /api/recipe_image/{*path}`), \
which takes up to 10 MB.",
),
note(
"Content type",
"JSON in and out, except where noted — raw recipe text is `text/plain`.",
),
],
error_intro: "Every failure returns the same shape, with the status code carrying the \
meaning:"
.to_string(),
error_example: r#"{ "error": "Recipe not found: Nope.cook" }"#.to_string(),
error_codes: vec![
note(
"400",
"malformed input: an invalid path, a bad query parameter, or a recipe that \
failed to parse.",
),
note(
"401",
"the server requires signing in to make changes, and the request carried no \
valid session cookie.",
),
note(
"403",
"the signed-in user's role does not allow this request (`Your role does not \
allow this change`); signing in again as the same user will not help. Also: \
the request was sent to a host name other than `localhost` or an IP address, \
or a browser request tried to modify recipes from an origin the server does \
not trust (another site). Start the server with `--cors-origin <ORIGIN>` to \
allow that origin and its host.",
),
note(
"404",
"the recipe, menu, or pantry section does not exist, or no pantry file is \
configured.",
),
note(
"413",
"the request body is over the size limit, or a picture has more pixels than \
the server will decode.",
),
note(
"415",
"a picture in a format the server cannot read. The body adds a `code`; see \
`PUT /api/recipe_image/{*path}`.",
),
note("500", "the server could not read or write a file."),
note(
"503",
"every language server session is in use, or the bridge is switched off. \
Only `GET /api/ws/lsp` returns this; see `--max-lsp-sessions`.",
),
],
}
}
fn section(id: &str, title: &str, description: &str, endpoints: Vec<EndpointDoc>) -> ApiSection {
ApiSection {
id: id.to_string(),
title: title.to_string(),
description: description.to_string(),
endpoints,
}
}
fn ep(method: &str, path: &str, summary: &str, description: &str) -> EndpointDoc {
EndpointDoc {
method: method.to_string(),
path: path.to_string(),
summary: summary.to_string(),
description: description.to_string(),
params: Vec::new(),
request_example: None,
response_example: None,
feature: None,
}
}
fn param(name: &str, kind: &str, type_name: &str, required: bool, description: &str) -> ParamDoc {
ParamDoc {
name: name.to_string(),
kind: kind.to_string(),
required,
type_name: type_name.to_string(),
description: description.to_string(),
}
}
fn path_param(name: &str, description: &str) -> ParamDoc {
param(name, "path", "string", true, description)
}
fn step_params() -> [ParamDoc; 2] {
[
param(
"section",
"query",
"integer",
false,
"With `step`: the step's section, from 1, counting every section of the \
recipe. Defaults to 1.",
),
param(
"step",
"query",
"integer",
false,
"A step's picture instead of the title picture: the step's number within its \
section, from 1. A step or section the saved recipe does not have, a value \
that is not a whole number from 1, or `section` without `step` returns `400`.",
),
]
}
impl EndpointDoc {
fn params(mut self, params: Vec<ParamDoc>) -> Self {
self.params = params;
self
}
fn request(mut self, json: &str) -> Self {
self.request_example = Some(json.trim().to_string());
self
}
fn response(mut self, json: &str) -> Self {
self.response_example = Some(json.trim().to_string());
self
}
fn requires(mut self, feature: &str) -> Self {
self.feature = Some(feature.to_string());
self
}
}
pub fn sections() -> Vec<ApiSection> {
vec![
recipes(),
menus(),
shopping_list(),
pantry(),
aisles(),
search_and_stats(),
realtime(),
sync(),
]
}
fn recipes() -> ApiSection {
section(
"recipes",
"Recipes",
"Browse, read, write and delete `.cook` files under the server's recipe directory. \
Paths are relative to that directory and may include subdirectories.",
vec![
ep(
"GET",
"/api/recipes",
"List every recipe as a directory tree",
"Returns the recipe tree rooted at the server's base path. Every node — \
root, directory, and file alike — carries the same four keys: `children`, \
`name`, `path`, `recipe`. `recipe` is `null` for a directory and non-null \
for a file; that is the discriminator, not the key's presence. Every \
`path`, and a recipe's `source.path`, is relative to the recipe directory, \
the root's being `\"\"`; it is what `GET /api/recipes/{*path}` takes. \
Where the directory is on the server is never sent. Menus \
(`.menu`) appear in the same tree as recipes. A recipe's `metadata` keeps \
the types its frontmatter used, except `tags`, which is always an array of \
strings: the comma-separated spelling `tags: breakfast, quick` is split \
into the same array the list spelling `tags: [breakfast, quick]` gives.",
)
.response(
r#"
{
"children": {
"Breakfast": {
"children": {
"Easy Pancakes": {
"children": {},
"name": "Easy Pancakes",
"path": "Breakfast/Easy Pancakes.cook",
"recipe": {
"metadata": {
"author": "CookCLI Team",
"servings": 2,
"tags": ["breakfast", "quick"],
"description": "Simple crepes that are perfect for a lazy weekend breakfast."
},
"source": {
"path": "Breakfast/Easy Pancakes.cook",
"source_type": "Path"
}
}
}
},
"name": "Breakfast",
"path": "Breakfast",
"recipe": null
}
},
"name": "seed",
"path": "",
"recipe": null
}
"#,
),
ep(
"GET",
"/api/recipes/{*path}",
"Read one parsed recipe",
"Parses the recipe and returns its ingredients, cookware, timers and steps. \
`grouped_ingredients` aggregates repeated ingredients and indexes back into \
`ingredients`. `inline_quantities` is also present alongside them at the top \
level of `recipe`. The `image` field is a URL under `/api/static/` (after any `--url-prefix`, \
each path segment percent-encoded) when the \
recipe has a title image, otherwise null. Frontmatter lands under \
`metadata.map` with the types it was written in, except `tags`, which is \
always an array of strings — `tags: breakfast, quick` is split on commas \
into the same array as `tags: [breakfast, quick]`.",
)
.params(vec![
path_param("path", "Recipe path relative to the recipe directory, e.g. `Breakfast/Easy Pancakes.cook`. The `.cook` extension is optional — the server tries the bare path first, then `.cook`, then `.menu`."),
param("scale", "query", "number", false, "Scaling factor applied during parsing. Defaults to 1. A non-numeric value returns a plain-text 400 (\"Failed to deserialize query string: ...\") from axum's query deserializer, not the page's usual JSON error envelope."),
])
.response(
r#"
{
"image": "/api/static/Breakfast/Easy%20Pancakes.jpg",
"scale": 2.0,
"recipe": {
"metadata": {
"map": {
"author": "CookCLI Team",
"servings": 4,
"tags": ["breakfast", "quick"],
"description": "Simple crepes that are perfect for a lazy weekend breakfast."
}
},
"ingredients": [
{
"name": "eggs",
"alias": null,
"note": null,
"modifiers": "",
"quantity": {
"scalable": true,
"unit": null,
"value": { "type": "number", "value": { "type": "regular", "value": 6.0 } }
},
"reference": null,
"relation": {
"reference_target": null,
"relation": {
"defined_in_step": true,
"referenced_from": [],
"type": "definition"
}
}
}
],
"grouped_ingredients": [
{
"index": 0,
"quantities": [
{
"scalable": true,
"unit": null,
"value": { "type": "number", "value": { "type": "regular", "value": 6.0 } }
}
]
}
],
"cookware": [],
"timers": [],
"sections": [
{
"content": [
{
"type": "step",
"value": {
"items": [
{ "type": "text", "value": "Crack the " },
{ "type": "ingredient", "index": 0 },
{ "type": "text", "value": " into a blender." }
]
}
}
]
}
]
}
}
"#,
),
ep(
"GET",
"/api/recipes/raw/{*path}",
"Read the unparsed Cooklang source",
"Returns the file's text verbatim with content type `text/plain`, including \
YAML frontmatter. Only `.cook` and `.menu` files are served. The extension is \
optional in the path — without one the server tries `.cook`, then `.menu`. \
A path with a component starting with `.` is refused with a 400, and any \
other file answers 404.",
)
.params(vec![path_param(
"path",
"Recipe path relative to the recipe directory.",
)])
.response(
r#"
---
servings: 2
tags: breakfast, quick
author: CookCLI Team
---
Crack the @eggs{3} into a blender, then add the @flour{125%g},
@milk{250%ml} and @sea salt{pinch}, and blitz until smooth.
"#,
),
ep(
"PUT",
"/api/recipes/{*path}",
"Create or overwrite a recipe",
"The request body is the raw Cooklang source as `text/plain` — not JSON. \
Writes are atomic (temp file plus rename). Only `.cook` and `.menu` files \
are written, with the same resolution as the raw endpoint: a path naming \
any other file, such as `config/aisle.conf`, writes `config/aisle.conf.cook` \
and leaves that file alone. If the file does not exist yet, it is created \
with the extension the path gives, or `.cook` — but the response's `path` echoes \
the request path verbatim and does not report that resolved filename. The \
parent directory must already exist: writing into a directory that is not \
there returns a 500 whose message talks about permissions even when the \
real cause is the missing directory.",
)
.params(vec![path_param(
"path",
"Recipe path relative to the recipe directory.",
)])
.request(
r#"
---
title: New Recipe
---
Mix the @flour{200%g} and @water{120%ml}.
"#,
)
.response(
r#"
{
"path": "Breakfast/New Recipe",
"status": "success"
}
"#,
),
ep(
"DELETE",
"/api/recipes/{*path}",
"Delete a recipe file",
"Permanently removes the file from disk. There is no undo and no trash.",
)
.params(vec![path_param(
"path",
"Recipe path relative to the recipe directory. The extension is optional, \
and only a `.cook` or `.menu` file is ever removed — the same resolution as \
the raw endpoint applies here too.",
)])
.response(
r#"
{
"status": "success",
"path": "Breakfast/Old Recipe.cook"
}
"#,
),
ep(
"POST",
"/api/recipe_rename/{*path}",
"Rename a recipe or menu file",
"Renames the file within its own folder; moving it to another folder is not \
possible. Its title and step pictures (`Recipe.jpg`, `Recipe.2.jpg`, \
`Recipe.1.2.jpg`) are renamed with it, except one another recipe or menu \
in the folder shows too. Every reference to it in the collection's `.cook` \
and `.menu` files (`@./Breakfast/Old Recipe{}`, `@../Old Recipe{}`) is \
rewritten to the new name — only the name, the rest of each file is kept \
byte for byte. A reference that cannot be rewritten exactly (an escape or \
a comment inside the name, another letter case, a file reached through a \
symbolic link, a file saved meanwhile) is left alone and listed in \
`references_skipped`, with its line when known; a file that could not be \
written is listed in `references_failed`, and the rename stands. The \
saved shopping list is not changed: `shopping_list_stale` says whether it \
still names the old path. Nothing is ever overwritten: `409` when a file \
already has the new name, when a picture would land on an existing file \
or on another recipe's picture name, or when renaming a recipe to the name \
of a menu beside it (references to the menu would reach the recipe). `400` \
for a name that is not a plain file name (a folder, a leading dot, \
characters Windows or a Cooklang reference cannot hold, a Windows device \
name), for a file or folder that is a symbolic link, and for the same \
name; `404` when the file does not exist.",
)
.params(vec![
path_param(
"path",
"Recipe or menu path relative to the recipe directory; the extension is \
optional, resolved as for the raw endpoint.",
),
param(
"name",
"body",
"string",
true,
"The new name, without folder. The file's own extension may be typed and \
is dropped; a recipe cannot be renamed to `.menu` or the other way round.",
),
])
.request(
r#"
{ "name": "New Recipe" }
"#,
)
.response(
r#"
{
"status": "success",
"path": "Breakfast/New Recipe.cook",
"references_updated": ["Plans/Week 1.menu"],
"references_skipped": [{ "file": "Lunch/Bowl.cook", "line": 4 }],
"references_failed": [],
"shopping_list_stale": false
}
"#,
),
ep(
"GET",
"/api/recipe_image/{*path}",
"Read a recipe's title and step pictures",
"Reports the picture the recipe page shows. `image` is a URL — under \
`/api/static/`, with the server's `--url-prefix`, or the value itself when \
the metadata names an `http(s)` address — and null when there is none. \
`source` says where it comes from: `metadata` when the recipe's `image` \
(or `images`, `picture`, `pictures`) metadata names it, which takes \
precedence over any file; `file` for a `Recipe.jpg`, `.jpeg`, `.png` or \
`.webp` beside the recipe; null for none. `sections` lists every section \
of the saved recipe that has steps — `section` is its position counting \
every section, `name` null for an unnamed one — with each step's number \
within the section, its text as plain words, and its picture's URL or \
null. The upload and removal endpoints answer with this same shape. With \
`step`, the answer is that step's `{ path, section, step, image, source }` \
instead, `source` being `file` or null; the upload and removal of a step \
picture answer with it too. A step's picture is `Recipe.S.N.ext` (section \
S, step N within it), else `Recipe.G.ext` with the step counted across \
every section — the order the recipe page looks in.",
)
.params(
[path_param(
"path",
"Recipe path relative to the recipe directory; the `.cook` extension is \
optional.",
)]
.into_iter()
.chain(step_params())
.collect(),
)
.response(
r#"
{
"path": "Breakfast/Easy Pancakes.cook",
"image": "/api/static/Breakfast/Easy%20Pancakes.jpg",
"source": "file",
"sections": [
{
"section": 1,
"name": null,
"steps": [
{ "step": 1, "text": "Whisk eggs, milk and flour.", "image": null },
{ "step": 2, "text": "Cook in a pan for 1 minute.", "image": "/api/static/Breakfast/Easy%20Pancakes.1.2.jpg" }
]
}
]
}
"#,
),
ep(
"PUT",
"/api/recipe_image/{*path}",
"Upload a recipe's title or step picture",
"The request body is the picture's bytes — not a multipart form. JPEG, PNG \
and WebP are accepted, up to 10 MB; the format is read from the bytes, not \
the `Content-Type`. The picture is turned upright from its Exif orientation, \
scaled down to at most 2048 px on its longer edge, laid over white if it has \
transparency, and saved as JPEG at `Recipe.jpg` beside the recipe — or, \
with `step`, at `Recipe.S.N.jpg`. It is always re-encoded, never stored as \
sent, so the file holds no Exif (GPS position included) and nothing that \
rode along after the image. The web editor scales a photo down in the \
browser before sending it, which is why 10 MB is plenty; another client may \
send the original. For the title, any `Recipe.jpeg`, `.png` or `.webp` from \
before is removed; step pictures (`Recipe.1.jpg`) are not touched. A picture \
the metadata names still wins over the uploaded file — see `source` in the \
response. For a step, that step's other files in both conventions and every \
extension are removed (`Recipe.S.N.png`, `Recipe.G.jpg`, …); the title \
picture and other steps' pictures are not touched. A step picture belongs \
to a position, not to the step's text: adding or removing an earlier step \
moves it onto another step. Errors carry a `code` next to `error`: `415` \
with `heif` for a HEIC or AVIF photo, which the server cannot read (an \
iPhone's own browser converts them to JPEG when uploading), `415` with \
`unsupported` for any other format, `400` with `invalid` for a file that \
does not decode, and `413` with `too_large` for one with more pixels than \
the decoder takes on. A body over 10 MB is refused with a plain-text `413` \
once the server has read past the limit.",
)
.params(
[path_param(
"path",
"Recipe path relative to the recipe directory; the `.cook` extension is \
optional. The recipe must exist.",
)]
.into_iter()
.chain(step_params())
.collect(),
)
.request("<the picture's bytes>")
.response(
r#"
{
"path": "Breakfast/Easy Pancakes.cook",
"section": 1,
"step": 2,
"image": "/api/static/Breakfast/Easy%20Pancakes.1.2.jpg",
"source": "file"
}
"#,
),
ep(
"DELETE",
"/api/recipe_image/{*path}",
"Remove a recipe's title or step picture",
"Deletes every `Recipe.jpg`, `.jpeg`, `.png` and `.webp` beside the recipe, \
for good — there is no undo and no trash. With `step`, deletes that step's \
`Recipe.S.N` and `Recipe.G` files in those extensions instead. Returns `404` \
when there was none. A picture named by the recipe's metadata is left \
alone; remove it by editing the recipe.",
)
.params(
[path_param(
"path",
"Recipe path relative to the recipe directory; the `.cook` extension is \
optional.",
)]
.into_iter()
.chain(step_params())
.collect(),
)
.response(
r#"
{
"path": "Breakfast/Easy Pancakes.cook",
"image": null,
"source": null
}
"#,
),
ep(
"GET",
"/api/static/{*path}",
"Fetch a recipe asset",
"Serves files straight from the recipe directory — this is where recipe images \
live. The `image` field returned by `GET /api/recipes/{*path}` is already a URL \
into this route. Responses carry `Cache-Control: no-cache`, so a browser checks \
back each time — a cheap `304` through the `ETag` when nothing changed — and a \
picture replaced under the same name shows up at once.",
)
.params(vec![path_param(
"path",
"Asset path relative to the recipe directory, e.g. `Breakfast/Easy Pancakes.jpg`.",
)]),
],
)
}
fn menus() -> ApiSection {
section(
"menus",
"Menus",
"`.menu` files group recipes into meal plans. These endpoints return a menu's \
structure — days, meals, and the recipes and loose ingredients in each.",
vec![
ep(
"GET",
"/api/menus",
"List every menu",
"Walks the recipe tree and returns only `.menu` files. Order is not stable — \
the tree walk is over a hash-ordered map, and consecutive calls can return \
the same menus in a different order. Do not depend on it.",
)
.response(
r#"
[
{ "name": "2 Day Plan", "path": "2 Day Plan.menu" },
{ "name": "Weekly Plan", "path": "Weekly Plan.menu" }
]
"#,
),
ep(
"GET",
"/api/menus/{*path}",
"Read one menu",
"Sections correspond to days; a `date` is extracted when the section name \
contains a `YYYY-MM-DD` date (the first one), e.g. `Day 1 (2026-03-04)` — the \
seed menus don't use that convention, so `date` is null below. A meal's `time` is likewise \
extracted from its header, e.g. `Breakfast (08:30):` yields \
`\"type\": \"Breakfast\", \"time\": \"08:30\"`; none of the seed menus set a \
time either, hence null throughout. Meal items are tagged by `kind`: \
`recipe_reference` points at another file; `ingredient` is a loose item \
written directly in the menu. Plain connecting text in the menu (e.g. \"with\") \
is dropped — only structured references and ingredients are returned. Returns \
400 if the path is not a menu file, 404 if it does not exist. The response \
below is trimmed to the first of this menu's two `sections`; the second \
follows the same shape. A `recipe_reference`'s `scale` is a ready-to-use \
multiplier for the referenced recipe, resolved from the menu's `{...}` \
notation per the Cooklang spec: a bare `{2}` is a raw multiplier, \
`{3%servings}` targets servings against the referenced recipe's own \
`servings` metadata, any other unit targets its `yield`, and `{}` means \
1. The example below shows `5.0` because the menu asks for \
`{10%servings}` and `Easy Pancakes` declares `servings: 2`. The `?scale` \
query multiplies these, so `?scale=2` yields `10.0`. \
`POST /api/shopping_list/add_menu` resolves references identically, so the \
two endpoints always agree. `metadata` values are strings, except `tags`, \
which is always an array of strings whichever way the frontmatter spelled \
it.",
)
.params(vec![
path_param(
"path",
"Menu path relative to the recipe directory, e.g. `2 Day Plan.menu`.",
),
param(
"scale",
"query",
"number",
false,
"Scaling factor applied to the whole menu. Defaults to 1.",
),
])
.response(
r#"
{
"name": "2 Day Plan",
"path": "2 Day Plan.menu",
"metadata": { "servings": "2" },
"sections": [
{
"name": "Day 1",
"date": null,
"meals": [
{
"type": "Breakfast",
"time": null,
"items": [
{
"kind": "recipe_reference",
"name": "./Breakfast/Easy Pancakes",
"path": "./Breakfast/Easy Pancakes.cook",
"scale": 5.0
},
{ "kind": "ingredient", "name": "maple syrup", "quantity": "2", "unit": "tbsp" },
{ "kind": "ingredient", "name": "coffee", "quantity": "1", "unit": "c" }
]
},
{
"type": "Lunch",
"time": null,
"items": [
{
"kind": "recipe_reference",
"name": "./lamb-chops",
"path": "./lamb-chops.cook",
"scale": 1.0
},
{ "kind": "ingredient", "name": "bread", "quantity": "2", "unit": "slices" },
{ "kind": "ingredient", "name": "butter", "quantity": "1", "unit": "tbsp" }
]
},
{
"type": "Dinner",
"time": null,
"items": [
{
"kind": "recipe_reference",
"name": "./Neapolitan Pizza",
"path": "./Neapolitan Pizza.cook",
"scale": 1.0
},
{ "kind": "ingredient", "name": "soy sauce", "quantity": "1", "unit": "tbsp" }
]
}
]
}
]
}
"#,
),
ep(
"POST",
"/api/plans/{*path}",
"Change a meal plan",
"Makes one change to a meal plan — a `.menu` with sections on two days or \
more — as its calendar page does: `add` a recipe to the end of a day's \
meal, `remove` a line, or `move` / `copy` a line to another day's meal. Only \
the lines concerned change; the rest of the file keeps its bytes. A day is a \
section whose name holds its `YYYY-MM-DD` date, `== Wednesday (2026-10-07) ==` \
or `= 2026-10-07 Dinner`, and a meal starts at a line that is only `Name:`, \
or at the section's heading for the meal named after its date; a missing day \
or meal is created, days in date order, meals in the order the plan's days \
name them. A line is \
named by its day, meal and place among the meal's bullets (from 0), plus \
its text without the bullet, which must match the file. `version` is the \
SHA-256 of the file's text, in hex, as the plan's page carries it; a change \
to any other version, or to a line that is no longer there, is refused with \
409 and the current `version`. `add` takes a recipe's path in the \
collection and writes `- @./Path{}`, or `- @./Path{2%servings}` with \
`servings`. Returns 400 for a menu dated on fewer than two days, a meal name \
that would not read back (`:`, brackets, …) or a recipe that does not \
exist, and 404 for a missing file. When sign-in is on, needs the `editor` \
role. The response carries the file's new `version`.",
)
.params(vec![
path_param(
"path",
"Plan path relative to the recipe directory, e.g. `Plans/October.menu`.",
),
param(
"op",
"body",
"string",
true,
"`add`, `remove`, `move` or `copy`.",
),
param(
"version",
"body",
"string",
true,
"The version of the plan the change was made to.",
),
param(
"date, meal",
"body",
"string",
true,
"The day (`YYYY-MM-DD`) and meal: where `add` adds, or the line's for the others.",
),
param(
"index, text",
"body",
"number, string",
false,
"`remove`, `move`, `copy`: the line's place in the meal and its text.",
),
param(
"to",
"body",
"object",
false,
"`move`, `copy`: `{ \"date\", \"meal\" }` the line goes to.",
),
param(
"recipe, servings",
"body",
"string, number",
false,
"`add`: the recipe's path, e.g. `Salads/Caprese.cook`, and optional servings.",
),
])
.request(
r#"
{
"version": "8e507b3bf24da02c9f3fa5c788eab55839e4ea7d37a1683ee8505ef177bde090",
"op": "move",
"date": "2026-10-07",
"meal": "Dinner",
"index": 0,
"text": "@./Risotto{}",
"to": { "date": "2026-10-09", "meal": "Breakfast" }
}
"#,
)
.response(
r#"
{
"status": "success",
"version": "8bb83c4f83bc739a1c5ea480a71c182e7a92c86b57e6478e58b4cea1aba562c7"
}
"#,
),
],
)
}
fn shopping_list() -> ApiSection {
section(
"shopping-list",
"Shopping List",
"Two distinct things live here. `POST /api/shopping_list` is stateless: send recipes, \
get an aggregated ingredient list back. Everything else operates on the server's \
persistent list, stored as `.shopping-list` and `.shopping-checked` in the recipe \
directory. Most of the endpoints that mutate the stored list respond `200 OK` with an \
empty body — only `GET /api/shopping_list/items`, `GET /api/shopping_list/checked`, \
and the stateless `POST /api/shopping_list` return JSON. (A third GET lives under \
this path in the router, `/api/shopping_list/events`, but it's a Server-Sent Events \
stream, not JSON — see the Realtime section.)",
vec![
ep(
"POST",
"/api/shopping_list",
"Aggregate ingredients across recipes",
"Stateless — nothing is stored. Ingredients with the same name are combined and \
unit-converted, then grouped into aisle categories from `aisle.conf`; a \
category with no matching entries is omitted from `categories` entirely, and \
ingredients that match no aisle category land in `other`, sorted \
alphabetically. Quantities are reduced by anything in `pantry.conf`; \
`pantry_items` lists the ingredient names that were found there (with a \
nonzero or `unlim` quantity) and subtracted. `checked` echoes the server's \
current persistent checked state, unrelated to the recipes in this request.",
)
.params(vec![
param(
"recipe",
"body",
"string",
true,
"Recipe path. The array may hold several.",
),
param(
"scale",
"body",
"number",
false,
"Scaling factor for this recipe. Defaults to 1.",
),
param(
"included_references",
"body",
"string[]",
false,
"Which sub-recipe references to expand. Omit to include all of them.",
),
])
.request(
r#"
[
{ "recipe": "Neapolitan Pizza", "scale": 2 },
{ "recipe": "Salads/Caprese", "included_references": ["Shared/Vinaigrette"] }
]
"#,
)
.response(
r#"
{
"categories": [
{
"category": "fruit and veg",
"items": [
{
"name": "ripe tomatoes",
"quantities": [
{
"scalable": true,
"unit": "large",
"value": { "type": "number", "value": { "type": "regular", "value": 3.0 } }
}
]
}
]
},
{
"category": "milk and dairy",
"items": [
{
"name": "fresh mozzarella",
"quantities": [
{
"scalable": false,
"unit": "g",
"value": { "type": "number", "value": { "type": "regular", "value": 200.0 } }
}
]
},
{
"name": "mozzarella cheese",
"quantities": [
{
"scalable": false,
"unit": "g",
"value": { "type": "number", "value": { "type": "regular", "value": 200.0 } }
}
]
}
]
},
{
"category": "tinned goods and baking",
"items": [
{
"name": "tipo zero flour",
"quantities": [
{
"scalable": false,
"unit": "g",
"value": { "type": "number", "value": { "type": "regular", "value": 1680.0 } }
}
]
},
{
"name": "fresh yeast",
"quantities": [
{
"scalable": false,
"unit": "g",
"value": { "type": "number", "value": { "type": "regular", "value": 3.2 } }
}
]
}
]
},
{
"category": "dried herbs and spices",
"items": [
{
"name": "salt",
"quantities": [
{
"scalable": false,
"unit": "tsp",
"value": {
"type": "number",
"value": { "type": "fraction", "value": { "whole": 0, "num": 1, "den": 8, "err": 0.0 } }
}
},
{
"scalable": false,
"unit": "g",
"value": { "type": "number", "value": { "type": "regular", "value": 49.2 } }
}
]
},
{
"name": "black pepper",
"quantities": [
{
"scalable": false,
"unit": "tsp",
"value": { "type": "number", "value": { "type": "regular", "value": 0.0625 } }
}
]
}
]
},
{
"category": "oils and dressings",
"items": [
{
"name": "Dijon mustard",
"quantities": [
{
"scalable": false,
"unit": "tsp",
"value": {
"type": "number",
"value": { "type": "fraction", "value": { "whole": 0, "num": 1, "den": 4, "err": 0.0 } }
}
}
]
},
{
"name": "honey",
"quantities": [
{
"scalable": false,
"unit": "tsp",
"value": {
"type": "number",
"value": { "type": "fraction", "value": { "whole": 0, "num": 1, "den": 4, "err": 0.0 } }
}
}
]
},
{
"name": "red wine vinegar",
"quantities": [
{
"scalable": false,
"unit": "ml",
"value": { "type": "number", "value": { "type": "regular", "value": 10.0 } }
}
]
}
]
},
{
"category": "other",
"items": [
{ "name": "basil leaves", "quantities": [] },
{
"name": "San Marzano tomato sauce",
"quantities": [
{
"scalable": false,
"unit": "tbsp",
"value": { "type": "number", "value": { "type": "regular", "value": 10.0 } }
}
]
},
{ "name": "semolina", "quantities": [] }
]
}
],
"pantry_items": ["flour", "water", "salt", "tipo zero flour", "olive oil", "fresh basil", "black pepper"],
"checked": []
}
"#,
),
ep(
"GET",
"/api/shopping_list/items",
"Read the stored recipe list",
"Returns the recipes currently on the shopping list, not their ingredients. \
An entry with a `recipes` array is a menu added via `add_menu`; its nested \
entries carry their own resolved scale and `included_references`, independent \
of whatever the same recipe's standalone entry (if any) was given. `name` is \
the `title` the recipe or menu declares, or its file name when it declares \
none or cannot be read; `included_reference_names` names each of \
`included_references` the same way, in the same order.",
)
.response(
r#"
[
{
"path": "Salads/Caprese.cook",
"name": "Caprese",
"scale": 2.0,
"included_references": [],
"included_reference_names": []
},
{
"path": "2 Day Plan.menu",
"name": "2 Day Plan",
"scale": 1.0,
"recipes": [
{
"path": "Breakfast/Easy Pancakes",
"name": "Easy Pancakes",
"scale": 5.0,
"included_references": [],
"included_reference_names": []
},
{
"path": "lamb-chops",
"name": "Sicilian-style Scottadito Lamb Chops",
"scale": 1.0,
"included_references": [],
"included_reference_names": []
},
{
"path": "Neapolitan Pizza",
"name": "Neapolitan Pizza",
"scale": 1.0,
"included_references": ["Shared/Pizza Dough"],
"included_reference_names": ["Pizza Dough"]
},
{
"path": "Salads/Caprese",
"name": "Caprese",
"scale": 1.0,
"included_references": ["Shared/Vinaigrette"],
"included_reference_names": ["Vinaigrette"]
},
{
"path": "Risotto",
"name": "Classic Risotto alla Milanese",
"scale": 1.0,
"included_references": [],
"included_reference_names": []
}
]
}
]
"#,
),
ep(
"POST",
"/api/shopping_list/add",
"Add one recipe to the stored list",
"Responds `200 OK` with an empty body. The display name is derived from the \
path server-side; a client-supplied name would be discarded, so it is not \
accepted. The path is not checked for existence or validity: adding a recipe \
that doesn't exist still returns 200 and the entry lands on the list — it \
then makes every subsequent `POST /api/shopping_list/compact` fail with 500 \
until it's removed, because compact re-aggregates the whole stored list and \
refuses to proceed if any entry fails to parse.",
)
.params(vec![
param(
"path",
"body",
"string",
true,
"Recipe path relative to the recipe directory.",
),
param(
"scale",
"body",
"number",
true,
"Scaling factor to store with the entry.",
),
param(
"included_references",
"body",
"string[]",
false,
"Which sub-recipe references to expand. Omit and no sub-recipes are \
expanded — unlike the stateless `POST /api/shopping_list`, where omitting \
this field means \"expand all\", omitting it here is not preserved through \
storage and reads back as an explicit empty array. Pass the reference \
paths explicitly if you want them expanded.",
),
])
.request(
r#"
{
"path": "Salads/Caprese.cook",
"scale": 2.0,
"included_references": ["Shared/Vinaigrette"]
}
"#,
),
ep(
"POST",
"/api/shopping_list/add_menu",
"Add every recipe in a menu",
"Stored as a single entry with the menu's recipes nested inside; each nested \
recipe's own sub-recipe references are resolved automatically (there is no \
`included_references` field to set here). Each nested recipe's scale is \
resolved from the menu reference: a bare `{2}` is a raw multiplier, \
`{3%servings}` targets 3 servings against the recipe's own `servings` \
metadata, and any other unit targets its `yield` metadata. Responds `200 OK` \
with an empty body; returns 404 if `path` does not exist, but does not check \
that it's actually a `.menu` file — pointing this at a plain recipe is \
accepted and stores it as if it were `POST /api/shopping_list/add` with that \
recipe's own references as `included_references`, which can leave the list in \
a state that later makes `compact` fail.",
)
.params(vec![
param(
"path",
"body",
"string",
true,
"Menu path relative to the recipe directory.",
),
param(
"scale",
"body",
"number",
true,
"Scaling factor applied to the whole menu.",
),
])
.request(
r#"
{
"path": "2 Day Plan.menu",
"scale": 1.0
}
"#,
),
ep(
"POST",
"/api/shopping_list/remove",
"Remove one recipe from the stored list",
"Also compacts the checked log, dropping checks for ingredients no longer \
referenced by any remaining recipe — best-effort; a compaction failure does \
not fail the remove itself. Responds `200 OK` with an empty body, or \
`409 Conflict` when the entry at `index` is no longer `path` — the list \
changed since it was read, and nothing was removed.",
)
.params(vec![
param(
"path",
"body",
"string",
true,
"Recipe path exactly as stored.",
),
param(
"index",
"body",
"integer",
false,
"Position of the entry in `GET /api/shopping_list/items`, counting from 0. \
The same recipe can be on the list more than once; without `index` the \
first entry with `path` is removed.",
),
])
.request(
r#"
{ "path": "Salads/Caprese.cook", "index": 1 }
"#,
),
ep(
"POST",
"/api/shopping_list/clear",
"Empty the stored list",
"Removes every recipe and all checked state. Responds `200 OK` with an empty body.",
),
ep(
"POST",
"/api/shopping_list/check",
"Mark an ingredient as bought",
"Appends the name to the checked log verbatim — the server does not validate \
it against the current aggregated list, so any string is accepted. Use a name \
as returned by `POST /api/shopping_list` for it to correspond to a real \
ingredient. Note that `GET /api/shopping_list/checked` lowercases every name \
it reads back, while the aggregated list from `POST /api/shopping_list` keeps \
original case — a client that diffs the two sets directly will fail to match \
any ingredient whose name isn't already all-lowercase, e.g. checking \
`\"Dijon mustard\"` here shows up as `\"dijon mustard\"` from `checked`. \
Responds `200 OK` with an empty body.",
)
.params(vec![param(
"name",
"body",
"string",
true,
"Aggregated ingredient name.",
)])
.request(
r#"
{ "name": "mozzarella cheese" }
"#,
),
ep(
"POST",
"/api/shopping_list/uncheck",
"Clear an ingredient's bought mark",
"Responds `200 OK` with an empty body.",
)
.params(vec![param(
"name",
"body",
"string",
true,
"Aggregated ingredient name.",
)])
.request(
r#"
{ "name": "mozzarella cheese" }
"#,
),
ep(
"GET",
"/api/shopping_list/checked",
"List checked ingredient names",
"Returns `[]` against a fresh list — nothing is checked until `check` is \
called. Every name comes back lowercased, regardless of the case it was \
checked with or the case `POST /api/shopping_list` uses for the same \
ingredient in its `categories` — compare case-insensitively, or lowercase \
the aggregated names yourself before diffing the two. Order is not stable: \
the underlying set is unordered, and consecutive calls can return the same \
names in a different order.",
)
.response(
r#"
["tipo zero flour", "mozzarella cheese"]
"#,
),
ep(
"POST",
"/api/shopping_list/compact",
"Drop stale checked entries",
"Re-aggregates the current list and removes checks for ingredients that are no \
longer in it. Refuses to compact (500) if any recipe fails to parse, rather \
than wiping checks based on a partial ingredient set. Responds `200 OK` with \
an empty body.",
),
],
)
}
fn aisles() -> ApiSection {
section(
"aisles",
"Aisles",
"Reads and changes `aisle.conf`, which groups the shopping list by store aisle: an \
`[aisle]` line, then one ingredient per line with its other names after `|`. \
Changes edit only the lines concerned, so comments and blank lines elsewhere in the \
file are kept. Each response carries the file's `revision`; send it back with a \
change and the change is refused with `409` if the file has been changed since, \
along with the current `revision`. Leave it out to apply the change regardless. \
Changes need the `shopper` role when sign-in is on.",
vec![
ep(
"GET",
"/api/aisles",
"Read the aisles",
"Aisles in file order, which is the order the shopping list uses. Each \
ingredient's first name is the one the shopping list shows; the others are \
merged into it. `warnings` lists the lines the parser skipped, such as a \
name listed twice. Without an aisle file, `configured` is `false`, \
`path` and `revision` are `null` and `aisles` is empty. The response below \
is trimmed to two of the seed's eight aisles and a few ingredients each.",
)
.response(
r#"
{
"configured": true,
"path": "/path/to/recipes/config/aisle.conf",
"revision": "edbf1fd2579f964e",
"aisles": [
{
"name": "fruit and veg",
"ingredients": [
{ "names": ["apples"] },
{ "names": ["avocado", "avocados"] }
]
},
{
"name": "milk and dairy",
"ingredients": [
{ "names": ["milk"] },
{ "names": ["butter"] }
]
}
],
"warnings": []
}
"#,
),
ep(
"POST",
"/api/aisles",
"Create an aisle file",
"Creates an empty `config/aisle.conf` in the recipe directory and answers \
`201` with the same shape as `GET /api/aisles`. `409` if an aisle file \
already exists, wherever it was found.",
),
ep(
"POST",
"/api/aisles/changes",
"Change the aisles",
"A change is named by `action`; the other fields depend on it. \
`add_aisle` (`name`, optional `position`, counted from 0; last when \
omitted), `rename_aisle` (`name`, `new_name`), `remove_aisle` (`name`; its \
ingredients go with it), `move_aisle` (`name`, `position`), \
`add_ingredient` (`aisle`, `names`), `update_ingredient` (`name`, `names`, \
optional `aisle` to move it), `remove_ingredient` (`name`), `add_names` \
(`name`, `names`: more names for that ingredient, ones it has skipped) and \
`merge_ingredients` (`names`, one per ingredient, and `main`: the one name \
the shopping list will show; the result keeps the place, aisle and comment \
of the ingredient `main` came from). An ingredient is found by any of its \
names, ignoring case. Send one change with its fields beside `revision`, or \
several as `changes`, a list: they are applied in order and written \
together, or not at all, and an error names the change that failed \
(`Change 4: …`). Answers with the aisles as `GET /api/aisles` does. `400` \
for a name the file cannot hold (empty, or containing `|` or `//`) or one \
already used — ingredient names ignoring case, as the shopping list \
matches them; `404` for an aisle or ingredient that does not exist, or \
when there is no aisle file; `409` for a stale `revision`.",
)
.params(vec![
param(
"action",
"body",
"string",
true,
"`add_aisle`, `rename_aisle`, `remove_aisle`, `move_aisle`, \
`add_ingredient`, `update_ingredient`, `remove_ingredient`, `add_names` \
or `merge_ingredients`. Not given when sending `changes`.",
),
param(
"changes",
"body",
"object[]",
false,
"Several changes, each with its own `action`, made together.",
),
param(
"revision",
"body",
"string",
false,
"The `revision` the change was made against.",
),
param(
"name",
"body",
"string",
false,
"The aisle, or any one of the ingredient's names.",
),
param(
"names",
"body",
"string[]",
false,
"An ingredient's names, the one to show first; for `merge_ingredients`, \
one name of each ingredient to group.",
),
param(
"main",
"body",
"string",
false,
"For `merge_ingredients`: the name the grouped ingredient goes by.",
),
param("aisle", "body", "string", false, "The aisle to put it in."),
param("new_name", "body", "string", false, "For `rename_aisle`."),
param(
"position",
"body",
"integer",
false,
"Where the aisle goes among the aisles, from 0.",
),
])
.request(
r#"
{
"revision": "edbf1fd2579f964e",
"changes": [
{ "action": "add_aisle", "name": "snacks" },
{ "action": "add_ingredient", "aisle": "snacks", "names": ["almonds"] },
{ "action": "add_names", "name": "butter", "names": ["Butter"] },
{ "action": "add_ingredient", "aisle": "snacks", "names": ["Butter"] }
]
}
"#,
)
.response(
r#"
{
"error": "Change 4: \"Butter\" is already in the \"milk and dairy\" aisle"
}
"#,
),
ep(
"GET",
"/api/aisles/raw",
"Read the aisle file as text",
"The file exactly as written, with its `revision`. `404` when there is no \
aisle file. The `content` below is cut short.",
)
.response(
r#"
{
"path": "/path/to/recipes/config/aisle.conf",
"content": "[fruit and veg]\napples\nbananas\ngrapes\n",
"revision": "edbf1fd2579f964e"
}
"#,
),
ep(
"PUT",
"/api/aisles/raw",
"Replace the aisle file",
"Writes `content` as the whole file once the aisle parser accepts it, and \
answers as `GET /api/aisles` does. Otherwise `400` naming the line, and the \
file is left alone.",
)
.params(vec![
param("content", "body", "string", true, "The new file."),
param(
"revision",
"body",
"string",
false,
"The `revision` the text was edited from.",
),
])
.request(
r#"
{
"content": "[a]\nx\n[b]\nx\n"
}
"#,
)
.response(
r#"
{
"error": "Line 4: Duplicate ingredient: 'x'"
}
"#,
),
ep(
"GET",
"/api/aisles/uncategorized",
"Ingredients without an aisle",
"The ingredients the recipes and menus use that no aisle names — what \
`cook doctor aisle` reports — with the files using each. `link` is what \
follows `/recipe/` in that file's page address. Scans the whole \
collection, so it takes longer than the other reads. The response below is \
trimmed to two of the seed's eleven.",
)
.response(
r#"
{
"total_recipes": 14,
"ingredients": [
{
"name": "San Marzano tomato sauce",
"recipes": [
{ "path": "Neapolitan Pizza.cook", "link": "Neapolitan Pizza" }
]
},
{
"name": "almonds",
"recipes": [
{ "path": "Weekly Plan.menu", "link": "Weekly Plan.menu" }
]
}
]
}
"#,
),
],
)
}
fn pantry() -> ApiSection {
section(
"pantry",
"Pantry",
"Reads and writes `pantry.conf`, a TOML file of what you already have at home. \
Quantities are typically `VALUE%UNIT`, e.g. `250%g`, but the field is just a string — \
`unlim` and plain counts like `12` appear untouched in the seed data. Every endpoint \
here returns 404 when no pantry file is configured.",
vec![
ep(
"GET",
"/api/pantry",
"Read the whole pantry",
"Top-level keys are section names — you choose them; `fridge`, `garden`, \
`pantry` and `spice rack` are just conventions used by the seed data. A bare \
`key = \"value\"` line at the top of the TOML file, outside any `[section]` \
header, is not an error: it is folded into a section literally named \
`general`, which is why it appears below even though `pantry.conf` never \
writes `[general]` itself. Item fields other than `name` are all optional and \
present only when set. The response below is trimmed to two sections of the \
seed pantry's five (`pantry` and `spice rack` also exist, with the same shape) \
to keep this example short.",
)
.response(
r#"
{
"fridge": [
{ "name": "butter", "expire": "2026-04-15", "quantity": "250%g" },
{ "name": "eggs", "bought": "2026-03-07", "quantity": "12" },
{ "name": "milk", "quantity": "2%l" },
{ "name": "parmesan cheese", "quantity": "200%g" },
{ "name": "sour cream", "quantity": "200%ml" }
],
"garden": [
{ "name": "fresh basil", "quantity": "unlim" },
{ "name": "fresh oregano", "quantity": "unlim" },
{ "name": "thyme", "quantity": "unlim" }
],
"general": [
{ "name": "water", "quantity": "unlim" }
]
}
"#,
),
ep(
"POST",
"/api/pantry/add",
"Add an item",
"Creates the section if it does not exist. The response claims success and \
reports a distinct item was appended even when the section already has an \
item of that name — but every write round-trips through a TOML serializer \
that keys each section's items by name, so a second item sharing a name with \
an existing one in the same section silently replaces it on disk rather than \
coexisting. Verified: adding `dup` twice with different quantities to a fresh \
section leaves exactly one `dup` item, with the second call's quantity.",
)
.params(vec![
param(
"section",
"body",
"string",
true,
"Section to add the item to.",
),
param("name", "body", "string", true, "Item name."),
param(
"quantity",
"body",
"string",
false,
"Amount as `VALUE%UNIT`, or any other string such as `unlim`.",
),
param(
"bought",
"body",
"string",
false,
"Purchase date. Accepts `YYYY-MM-DD`, `DD.MM.YYYY`, `DD/MM/YYYY`, \
`MM/DD/YYYY`, `YYYY.MM.DD` or `DD-MM-YYYY`.",
),
param(
"expire",
"body",
"string",
false,
"Expiry date, same accepted formats as `bought`.",
),
param(
"low",
"body",
"string",
false,
"Threshold below which the item counts as running low. Compared against \
`quantity` only when both share the same unit; see \
`GET /api/pantry/depleted`.",
),
])
.request(
r#"
{
"section": "fridge",
"name": "yogurt",
"quantity": "1%l",
"low": "2%l"
}
"#,
)
.response(
r#"
{
"success": true,
"message": "Added yogurt to fridge"
}
"#,
),
ep(
"PUT",
"/api/pantry/{section}/{name}",
"Update an item",
"Only the fields present in the body are changed; omitted fields keep their \
current values. Returns 404 if the section does not exist. A name that \
matches nothing inside a valid section does not 404 — that case still \
rewrites the file (a no-op) and responds `200` with a success message \
naming the item that was never found. If more than one item in the section \
shares the target name, only the first one is updated.",
)
.params(vec![
path_param("section", "Section containing the item."),
path_param("name", "Item name."),
param("quantity", "body", "string", false, "New amount."),
param("bought", "body", "string", false, "New purchase date."),
param("expire", "body", "string", false, "New expiry date."),
param("low", "body", "string", false, "New low threshold."),
])
.request(
r#"
{ "quantity": "500%g" }
"#,
)
.response(
r#"
{
"success": true,
"message": "Updated butter in fridge"
}
"#,
),
ep(
"DELETE",
"/api/pantry/{section}/{name}",
"Remove an item",
"The section is deleted too if it becomes empty. Returns 404 if the section \
does not exist, but — like `PUT` — responds `200` with a success message even \
when no item in the section actually has that name; nothing is removed and the \
file is rewritten unchanged.",
)
.params(vec![
path_param("section", "Section containing the item."),
path_param("name", "Item name."),
])
.response(
r#"
{
"success": true,
"message": "Removed butter from fridge"
}
"#,
),
ep(
"GET",
"/api/pantry/expiring",
"List items expiring soon",
"Sorted most urgent first. `days_remaining` goes negative for items that have \
already expired, and those are always included regardless of the window — the \
seed pantry's `butter` (`expire = \"2026-04-15\"`) is already expired relative \
to today, so it shows up even with the default 7-day window.",
)
.params(vec![param(
"days",
"query",
"number",
false,
"Look-ahead window in days. Defaults to 7. Negative values return 400.",
)])
.response(
r#"
[
{
"section": "fridge",
"name": "butter",
"expire": "2026-04-15",
"days_remaining": -116
}
]
"#,
),
ep(
"GET",
"/api/pantry/depleted",
"List items running low",
"An item counts as low when its `quantity` has fallen to or below its `low` \
threshold and the two share the same unit. Returns `[]` against the unmodified \
seed pantry, which has no `low` field set on anything.",
)
.response(
r#"
[
{ "section": "fridge", "name": "yogurt", "low": "2%l" }
]
"#,
),
],
)
}
fn search_and_stats() -> ApiSection {
section(
"search-stats",
"Search & Stats",
"Collection-wide queries.",
vec![
ep(
"GET",
"/api/search",
"Full-text recipe search",
"Matches against recipe names and content. Menus are searched alongside \
recipes. `q` is required: omitting it entirely returns a plain-text 400 from \
axum's query deserializer (`Failed to deserialize query string: missing field \
q`), not the page's usual JSON error envelope — the same shape as the \
`scale` parameter's failure mode on `GET /api/recipes/{*path}`. A present but \
empty `q=` is not rejected, though: it matches everything and returns the \
whole collection.",
)
.params(vec![param(
"q",
"query",
"string",
true,
"Search term. Omitting the parameter returns 400; an empty value matches \
everything.",
)])
.response(
r#"
[
{ "name": "Neapolitan Pizza", "path": "Neapolitan Pizza.cook" },
{ "name": "Pizza Dough", "path": "Shared/Pizza Dough.cook" },
{ "name": "2 Day Plan", "path": "2 Day Plan.menu" },
{ "name": "Weekly Plan", "path": "Weekly Plan.menu" }
]
"#,
),
ep(
"GET",
"/api/stats",
"Collection counts",
"Pantry counts are all zero when no pantry file is configured, or when it fails \
to read or parse. `pantry_expiring_count` uses a fixed 7-day window regardless \
of what `GET /api/pantry/expiring?days=` would be called with.",
)
.response(
r#"
{
"recipe_count": 12,
"menu_count": 2,
"pantry_item_count": 29,
"pantry_expiring_count": 1,
"pantry_depleted_count": 0
}
"#,
),
ep(
"GET",
"/api/reload",
"Reload recipes (no-op)",
"Kept for client compatibility. The server reads from disk on every request, \
so there is no cache to clear and this endpoint does nothing besides log and \
return this fixed response.",
)
.response(
r#"
{
"status": "success",
"message": "Recipes will be refreshed from disk on next request"
}
"#,
),
ep(
"POST",
"/api/reload",
"Reload recipes (no-op)",
"Identical to the GET form; both verbs are registered on the same route and \
accepted.",
)
.response(
r#"
{
"status": "success",
"message": "Recipes will be refreshed from disk on next request"
}
"#,
),
],
)
}
fn realtime() -> ApiSection {
section(
"realtime",
"Realtime",
"Long-lived connections. Neither of these returns a normal JSON response.",
vec![
ep(
"GET",
"/api/shopping_list/events",
"Server-sent events for shopping list changes",
"Emits a `change` event whenever `.shopping-list` or `.shopping-checked` is \
written, created, replaced or removed on disk — by any client of this server \
(a client's own changes are announced back to it too), by CookCloud sync, or \
by hand. Reading the files, through this API or otherwise, never emits one. \
The event's `file` field is `\"list\"` or `\"checked\"`, naming which file \
changed (captured live below by editing the shopping list in a second shell \
while connected). It is not a snapshot of what changed, so the intended \
pattern is to re-fetch on each event rather than to apply a diff: after \
`\"checked\"` only the ticks changed, and `GET /api/shopping_list/checked` has \
them; after `\"list\"`, regenerate the list. A subscriber that falls too far \
behind to be told every change is sent a `\"list\"` event instead. A `ping` \
keep-alive comment is sent every 30 seconds. If the filesystem watcher failed \
to start, the stream still connects and returns 200 but never emits an event.",
)
.response(
r#"
event: change
data: {"file":"list"}
event: change
data: {"file":"checked"}
"#,
),
ep(
"GET",
"/api/ws/lsp",
"Language server bridge (websocket)",
"Upgrades to a websocket (verified: a plain WebSocket handshake against this \
path returns `101 Switching Protocols`) that bridges to a `cook lsp` \
subprocess, providing diagnostics and completions to the built-in editor. \
Each websocket text message is one bare JSON-RPC message; the bridge adds and \
strips the `Content-Length` framing LSP uses over stdio. The language server's \
workspace root is always the server's recipe directory: `initialize` has its \
`rootUri`, `rootPath` and `workspaceFolders` replaced, and \
`workspace/didChangeWorkspaceFolders` is dropped, as is any message that is \
not a single JSON object. Browsers apply no CORS to websockets, so a \
handshake is refused with `403` when its `Origin` is neither a `--cors-origin` \
nor the server's own page reached by IP address or `localhost`. Clients that \
send no `Origin` are unaffected, and `--no-csrf-check` turns the check off. \
Each open socket holds a subprocess, so no more than `--max-lsp-sessions` of \
them (8 by default) run at once; a handshake beyond that is refused with \
`503` and the usual JSON error body instead of being upgraded, and succeeds \
once an earlier socket closes. `--max-lsp-sessions 0` turns the endpoint off, \
which is worth doing when serving `--host` on a network you do not control. \
Not a REST endpoint and not usable with a plain HTTP client.",
),
],
)
}
fn sync() -> ApiSection {
section(
"sync",
"Sync",
"Sign in to Cook Cloud and sync recipes across devices (Cook Basic). These four endpoints exist \
only when CookCLI is built with the `sync` feature, which is on by default — the \
badge on each entry marks that. Because the router is read as source text \
(`include_str!`), they are documented unconditionally rather than silently \
disappearing from this page in a build that lacks the feature. Authentication uses \
an OAuth device-code flow: start a login, show the user the code, then poll status \
until it completes.",
vec![
ep(
"GET",
"/api/sync/status",
"Current sync and login state",
"`pending_login` is non-null while a device-code login is in progress; poll \
this endpoint to detect completion. `expires_in_secs` counts down to when the \
pending login expires. Captured live against a fresh server with no session.",
)
.requires("sync")
.response(
r#"
{
"logged_in": false,
"email": null,
"syncing": false,
"pending_login": null
}
"#,
),
ep(
"POST",
"/api/sync/login",
"Start a device-code login",
"Illustrative response — derived field-by-field from `LoginResponse` in \
`src/server/handlers/sync.rs`, not captured from a completed cook.md \
exchange (that needs real network and credentials, which this reference was \
not authenticated against). Show `user_code` to the user and send them to \
`verification_uri`, or open `verification_uri_complete`, which pre-fills the \
code. Takes no request body. Returns 400 if already logged in, 409 if a \
login is already in progress, and 502 if cook.md is unreachable — the 502 \
path was verified live, by calling this endpoint with no network reachable: \
it returned `502` with `{\"error\": \"cook.md unreachable: ...\"}`.",
)
.requires("sync")
.response(
r#"
{
"user_code": "ABCD-EFGH",
"verification_uri": "https://cook.md/device",
"verification_uri_complete": "https://cook.md/device?code=ABCD-EFGH",
"expires_in_secs": 900
}
"#,
),
ep(
"POST",
"/api/sync/cancel_login",
"Abandon a pending login",
"`cancelled` is false when there was no login in progress — captured live \
against a fresh server with nothing pending. Takes no request body.",
)
.requires("sync")
.response(
r#"
{ "cancelled": false }
"#,
),
ep(
"POST",
"/api/sync/logout",
"Sign out and stop syncing",
"Clears the stored session and halts the background sync task. Always \
responds `200` with `{\"ok\": true}`, even when no session exists — captured \
live against a fresh server with no session file. Takes no request body.",
)
.requires("sync")
.response(
r#"
{ "ok": true }
"#,
),
],
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn api_body(source: &str) -> Option<&str> {
let start = source.find("fn api(")?;
let rest = &source[start..];
let open = rest.find('{')?;
let mut depth = 0usize;
for (i, c) in rest[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(&rest[open..=open + i]);
}
}
_ => {}
}
}
None
}
fn router_paths(source: &str) -> BTreeSet<String> {
const MARKER: &str = ".route(";
let Some(body) = api_body(source) else {
return BTreeSet::new();
};
for shape in [".merge(", ".nest(", ".nest_service("] {
assert!(
!body.contains(shape),
"api() uses `{shape}` — routes registered in the merged/nested router are \
invisible to this scan, so the completeness check (added with the sync \
section) would pass vacuously for them. Teach `router_paths` to follow it, \
or document those paths via NOT_ROUTER_REGISTERED."
);
}
let mut paths = BTreeSet::new();
let mut rest = body;
while let Some(i) = rest.find(MARKER) {
rest = &rest[i + MARKER.len()..];
let after_marker = rest.trim_start();
let Some(inside) = after_marker.strip_prefix('"') else {
let preview: String = after_marker.chars().take(60).collect();
panic!(
"a `.route(` in api() is not followed by a string literal, near: {preview:?}\n\
The extractor only understands plain string paths. A const path or a raw \
string literal would be skipped silently, letting an undocumented endpoint \
through the drift guard. Teach `router_paths` this shape rather than \
removing the check."
);
};
let Some(end) = inside.find('"') else {
unreachable!("unterminated string literal in api() — source would not compile")
};
paths.insert(format!("/api{}", &inside[..end]));
rest = &inside[end..];
}
paths
}
const FIXTURE: &str = r#"
fn serve_static() {
.route("/static/{*file}", get(serve_static))
}
fn api(_state: &AppState) -> Result<Router<Arc<AppState>>> {
let router = Router::new()
.route("/shopping_list", post(handlers::shopping_list))
.route(
"/shopping_list/items",
get(handlers::get_shopping_list_items),
)
.route("/pantry/{section}/{name}", axum::routing::delete(h))
.route("/pantry/{section}/{name}", axum::routing::put(h))
.route("/recipes/{*path}", get(h).put(h).delete(h));
#[cfg(feature = "sync")]
let router = router.route("/sync/status", get(handlers::sync_status));
Ok(router)
}
"#;
#[test]
fn extracts_api_routes_with_prefix() {
let paths = router_paths(FIXTURE);
assert!(paths.contains("/api/shopping_list"));
assert!(paths.contains("/api/recipes/{*path}"));
}
#[test]
fn ignores_routes_defined_before_the_api_fn() {
let paths = router_paths(FIXTURE);
assert!(
!paths.contains("/api/static/{*file}"),
"the /static/{{*file}} route lives outside api() and must not be collected"
);
}
#[test]
fn deduplicates_paths_registered_once_per_method() {
let paths = router_paths(FIXTURE);
assert!(paths.contains("/api/pantry/{section}/{name}"));
assert_eq!(paths.len(), 5, "expected 5 distinct paths, got {paths:?}");
}
#[test]
fn includes_feature_gated_routes() {
let paths = router_paths(FIXTURE);
assert!(paths.contains("/api/sync/status"));
}
#[test]
fn extracts_multiline_route_calls() {
let paths = router_paths(FIXTURE);
assert!(paths.contains("/api/shopping_list/items"));
}
#[test]
fn extracts_wrapped_routes_from_the_real_router() {
let paths = router_paths(include_str!("../server/mod.rs"));
for expected in [
"/api/shopping_list/items",
"/api/shopping_list/add_menu",
"/api/shopping_list/remove",
"/api/shopping_list/uncheck",
"/api/pantry/{section}/{name}",
"/api/recipes/{*path}",
] {
assert!(
paths.contains(expected),
"{expected} is missing from the extracted router paths"
);
}
assert!(
!paths.contains("/api/static/{*file}"),
"the outer router's static route must not be collected"
);
assert!(
paths.iter().all(|p| p.starts_with("/api/")),
"every extracted path should carry the /api nest prefix"
);
}
#[test]
fn ignores_routes_defined_after_the_api_fn() {
const TRAILING: &str = r#"
fn api(_state: &AppState) -> Result<Router<Arc<AppState>>> {
let router = Router::new().route("/inside", get(h));
Ok(router)
}
async fn something_else() {
other.route("/outside", get(h));
}
"#;
let paths = router_paths(TRAILING);
assert!(paths.contains("/api/inside"));
assert!(
!paths.contains("/api/outside"),
"routes outside api() are not nested under /api and must not be collected"
);
}
#[test]
#[should_panic(expected = "not followed by a string literal")]
fn rejects_route_shapes_it_cannot_read() {
const CONST_PATH: &str = r#"
fn api(_state: &AppState) -> Result<Router<Arc<AppState>>> {
let router = Router::new().route(ROUTES_RECIPES, get(h));
Ok(router)
}
"#;
let _ = router_paths(CONST_PATH);
}
#[test]
#[should_panic(expected = "invisible to this scan")]
fn rejects_merged_sub_routers() {
const MERGED: &str = r#"
fn api(_s: &A) -> R {
let router = Router::new()
.route("/stats", get(h))
.merge(sync_router());
Ok(router)
}
fn sync_router() -> Router { Router::new().route("/sync/status", get(h)) }
"#;
let _ = router_paths(MERGED);
}
const NOT_ROUTER_REGISTERED: &[&str] = &["/api/static/{*path}"];
fn documented_paths() -> BTreeSet<String> {
sections()
.iter()
.flat_map(|s| s.endpoints.iter())
.map(|e| e.path.clone())
.collect()
}
#[test]
fn all_methods_are_known_verbs() {
const KNOWN: &[&str] = &["GET", "POST", "PUT", "DELETE"];
for section in sections() {
for endpoint in section.endpoints {
assert!(
KNOWN.contains(&endpoint.method.as_str()),
"unknown HTTP method {:?} on {}",
endpoint.method,
endpoint.path
);
}
}
}
#[test]
fn no_documented_path_is_stale() {
let router = router_paths(include_str!("../server/mod.rs"));
for path in documented_paths() {
if NOT_ROUTER_REGISTERED.contains(&path.as_str()) {
continue;
}
assert!(
router.contains(&path),
"{path} is documented in api_docs.rs but no longer exists in the router. \
Remove or correct the doc entry."
);
}
}
#[test]
fn every_router_path_is_documented() {
let documented = documented_paths();
for path in router_paths(include_str!("../server/mod.rs")) {
assert!(
documented.contains(&path),
"{path} is registered in the router but missing from api_docs.rs. \
Add a doc entry for it."
);
}
}
#[test]
fn exemptions_are_all_documented() {
let documented = documented_paths();
for path in NOT_ROUTER_REGISTERED {
assert!(
documented.contains(*path),
"{path} is exempted from the router check but is not documented — \
remove the stale exemption"
);
}
}
#[test]
fn descriptions_contain_no_unsupported_markdown() {
fn check(field: &str, text: &str, where_: &str) {
for marker in ["**", "__"] {
assert!(
!text.contains(marker),
"{where_} {field} contains Markdown emphasis {marker:?}, which the \
inline_code filter does not render — it will show as literal \
characters. Rephrase, or use backticks for inline code."
);
}
}
for section in sections() {
check("description", §ion.description, §ion.title);
for e in section.endpoints {
let where_ = format!("{} {}", e.method, e.path);
check("summary", &e.summary, &where_);
check("description", &e.description, &where_);
for p in e.params {
check(
&format!("param {:?} description", p.name),
&p.description,
&where_,
);
}
}
}
}
#[test]
fn all_param_kinds_are_known() {
const KNOWN: &[&str] = &["path", "query", "body"];
for section in sections() {
for endpoint in section.endpoints {
for p in endpoint.params {
assert!(
KNOWN.contains(&p.kind.as_str()),
"unknown param kind {:?} on {} {}",
p.kind,
endpoint.method,
endpoint.path
);
}
}
}
}
#[test]
fn section_ids_are_unique() {
let ids: Vec<String> = sections().iter().map(|s| s.id.clone()).collect();
let unique: BTreeSet<&String> = ids.iter().collect();
assert_eq!(ids.len(), unique.len(), "duplicate section id in {ids:?}");
}
}