mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
`withIdempotency` has made writes safe to retry since v1.5.x. It was described
in route comments and in no schema, so an app generated against
`docs/api/openapi.yaml` could not learn that a retry was safe. The iOS side
asked for it; the mechanism does not change, only what it says about itself.
34 operations now declare `Idempotency-Key` and the `X-Idempotent-Replay`
header, plus the 409 that refuses a duplicate still in flight. The description
leads with the one silent failure in the mechanism: a key that misses
`^[A-Za-z0-9_\-:.]{8,128}$` is IGNORED rather than refused, so the write
proceeds unprotected and looks exactly like a request that carried no key.
## What the work turned up
Three things the contract had wrong or missing, none of them the thing being
built:
- `/api/insights/chat` describes `withIdempotency()` in its header comment and
applies it nowhere. The Coach chat is not retry-safe. Its prose says it is.
- `/api/measurements` exports GET, POST, PUT and DELETE and wraps only POST.
Publishing the header per PATH would have promised a safe retry on two
methods that do not honour one — the dangerous direction, so the guard works
per OPERATION.
- `/api/insights/feedback` and `/api/admin/backups/{id}/restore` are not in the
document at all. Both are named in the guard's exemption list with a reason,
not filtered out silently.
## The guard
`idempotency-contract-publication.test.ts` reads the expected set out of the
route tree on every run — nothing is hand-listed, so it cannot fall behind the
code. It fails in both directions: a new wrapped route that forgets to publish,
and a published claim no handler honours.
A fourth assertion checks the exemption list itself is still true. It earned
its place immediately: the first draft exempted the whole `/api/medications`
family on the strength of a grep for path literals that came back empty. The
paths are not written as literals there; the emitted document carries 19 of
them. The test went red, the claim was wrong, the list is two entries now.
Measure the artefact, not the source that builds it.
164 lines
6.8 KiB
TypeScript
164 lines
6.8 KiB
TypeScript
/**
|
|
* OpenAPI route table — nutrient intake sync (v1.28).
|
|
*
|
|
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request
|
|
* and response bodies come from `src/lib/validations/nutrients.ts` so
|
|
* the wire contract stays single-source. The batch description embeds
|
|
* the code ↔ HealthKit-identifier ↔ unit table generated from the
|
|
* code-side catalog — the iOS client reads the pinned HK query unit
|
|
* per type from here (this is the documented home of that table).
|
|
*/
|
|
import type { ZodOpenApiObject } from "zod-openapi";
|
|
|
|
import { NUTRIENT_DEFINITIONS } from "@/lib/nutrients/catalog";
|
|
import {
|
|
nutrientBatchSchema,
|
|
nutrientBatchResponseSchema,
|
|
nutrientDailyQuerySchema,
|
|
nutrientDailySeriesSchema,
|
|
nutrientOverviewQuerySchema,
|
|
nutrientOverviewSchema,
|
|
nutrientWaterWriteSchema,
|
|
nutrientWaterWriteResponseSchema,
|
|
} from "@/lib/validations/nutrients";
|
|
import {
|
|
dataEnvelope,
|
|
errorEnvelope,
|
|
idempotencyKeyParameter,
|
|
idempotentWrite,
|
|
recordRefusal,
|
|
stdResponses,
|
|
} from "./shared";
|
|
|
|
const NUTRIENTS_DISABLED_DESCRIPTION =
|
|
"The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is on.";
|
|
|
|
const moduleDisabled = {
|
|
"403": {
|
|
description: NUTRIENTS_DISABLED_DESCRIPTION,
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
} as const;
|
|
|
|
/**
|
|
* The catalog table, rendered into the endpoint description so the
|
|
* OpenAPI file is the one authoritative home of the code ↔ HK ↔ unit
|
|
* contract (µg is `ug` on the wire; water is mL; never IU).
|
|
*/
|
|
const catalogTable = NUTRIENT_DEFINITIONS.map(
|
|
(d) => `| ${d.code} | ${d.hkIdentifier} | ${d.unit} |`,
|
|
).join("\n");
|
|
|
|
export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
|
"/api/nutrients/batch": {
|
|
post: {
|
|
parameters: [idempotencyKeyParameter],
|
|
tags: ["Nutrients"],
|
|
summary: "Ingest micronutrient day totals (v1.28)",
|
|
description:
|
|
"Bulk upsert of supplement-style daily totals synced from HealthKit — vitamins, minerals, water, caffeine. Day totals only: compute HKStatisticsCollectionQuery cumulative sums day-anchored in the user's current IANA timezone; `day` is yyyy-MM-dd in that timezone (the same rule as the `stats:` day keys). Never post raw samples. Upsert key is (day, nutrient): a re-post replaces the stored total and reports `updated`. Post the current + previous local day per sync; backfill 30 days on first enable. Max 500 entries; Idempotency-Key supported; rate limit 60/min. Per-entry skips (`unit_mismatch` | `value_out_of_range` | `day_invalid`) are terminal — log and drop, do not retry the entry. Behind the opt-in `nutrients` module (403 `module.disabled` when off). Query HealthKit with exactly the listed unit per code (`ug` = microgram; do not convert to IU):\n\n| code | HealthKit identifier | unit |\n| --- | --- | --- |\n" +
|
|
catalogTable,
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: nutrientBatchSchema } },
|
|
},
|
|
responses: {
|
|
...idempotentWrite(),
|
|
"200": {
|
|
description:
|
|
"Batch processed; per-entry statuses inserted | updated | skipped.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
nutrientBatchResponseSchema,
|
|
"NutrientBatchEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...moduleDisabled,
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/nutrients": {
|
|
get: {
|
|
tags: ["Nutrients"],
|
|
summary: "Synced-nutrient window summary (v1.28)",
|
|
description:
|
|
"Per-nutrient summary of the last `days` days (default 14): latest synced day + total and the days-with-data count, in catalog order. Read-only; feeds the settings card. Behind the opt-in `nutrients` module.",
|
|
requestParams: { query: nutrientOverviewQuerySchema },
|
|
responses: {
|
|
"200": {
|
|
description: "The window summary.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
nutrientOverviewSchema,
|
|
"NutrientOverviewEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION),
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/nutrients/daily": {
|
|
get: {
|
|
tags: ["Nutrients"],
|
|
summary: "One nutrient's day-bucketed series (v1.29)",
|
|
description:
|
|
"Dense per-day series for one catalog code over the last `days` days (default 30) — one entry per calendar day, 0 for a day with no logged data, summed ACROSS SOURCES (a day may carry both an APPLE_HEALTH row and a MANUAL row since migration 0249). Feeds the `/insights/nutrients` hydration + caffeine charts. Also returns the EFSA reference resolved against the caller's profile sex — `null` when the profile has no sex on file (never guessed). Behind the opt-in `nutrients` module.",
|
|
requestParams: { query: nutrientDailyQuerySchema },
|
|
responses: {
|
|
"200": {
|
|
description: "The day-bucketed series + resolved reference.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
nutrientDailySeriesSchema,
|
|
"NutrientDailySeriesEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION),
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/nutrients/water": {
|
|
post: {
|
|
parameters: [idempotencyKeyParameter],
|
|
tags: ["Nutrients"],
|
|
summary: "Manual water quick-add (v1.29)",
|
|
description:
|
|
'Writes ONLY the `source="MANUAL"` row for `(day, "water")` — never the `source="APPLE_HEALTH"` row the batch endpoint owns, so a manual entry and an Apple sync coexist instead of one clobbering the other. `mode: "add"` increments the manual day total (quick-add chips); `mode: "set"` overwrites it (the "edit today\'s total" undo path — there is no per-entry ledger). `day` defaults to the caller\'s current local day when omitted. Idempotency-Key supported; rate limit 60/min. Behind the opt-in `nutrients` module.',
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
"application/json": { schema: nutrientWaterWriteSchema },
|
|
},
|
|
},
|
|
responses: {
|
|
...idempotentWrite(),
|
|
"200": {
|
|
description: "The MANUAL water row after the write.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
nutrientWaterWriteResponseSchema,
|
|
"NutrientWaterWriteEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION),
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
};
|