From 5a7d2526c958721811c0feff061e07832ddc9c27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 18:17:45 +0200 Subject: [PATCH] chore(openapi): regenerate spec for /api/workouts/batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the new typed workout batch ingest endpoint in the OpenAPI route table so the W14a hard-flip CI gate stays green: - Annotate createBatchWorkoutSchema with .meta() so it lands as a named component (CreateBatchWorkoutRequest). - Define WorkoutBatchEntryResult + WorkoutBatchResponse — the per- entry status envelope the iOS sync cursor decodes against. - Register POST /api/workouts/batch with the 200 / 400 / 413 / 401 / 422 / 429 response surface so the Swift DTO generator sees the full contract. - Tagged under Measurements rather than a new tag so the iOS client's sync surface stays in one section. Regenerated docs/api/openapi.yaml via pnpm openapi:generate; pnpm openapi:check confirms the spec is in sync with the underlying schemas. --- docs/api/openapi.yaml | 260 ++++++++++++++++++++++++++++++++++++++ src/lib/openapi/routes.ts | 70 ++++++++++ 2 files changed, 330 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 735f44489..51bcd6e55 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -164,6 +164,23 @@ components: - data - error type: object + BatchWorkoutsResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/WorkoutBatchResponse" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object CoachMessageFeedbackRequest: description: Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars. properties: @@ -1431,6 +1448,66 @@ components: required: - refreshToken type: object + WorkoutBatchEntryResult: + additionalProperties: false + properties: + index: + maximum: 9007199254740991 + minimum: 0 + type: integer + reason: + type: string + status: + enum: + - inserted + - duplicate + - skipped + type: string + required: + - index + - status + type: object + WorkoutBatchResponse: + additionalProperties: false + properties: + duplicates: + maximum: 9007199254740991 + minimum: 0 + type: integer + entries: + items: + $ref: "#/components/schemas/WorkoutBatchEntryResult" + type: array + inserted: + maximum: 9007199254740991 + minimum: 0 + type: integer + processed: + maximum: 9007199254740991 + minimum: 0 + type: integer + skipped: + items: + additionalProperties: false + properties: + index: + maximum: 9007199254740991 + minimum: 0 + type: integer + reason: + type: string + required: + - index + - reason + type: object + type: array + required: + - processed + - inserted + - duplicates + - skipped + - entries + type: object securitySchemes: bearerAuth: bearerFormat: hlk_<64hex> @@ -2302,6 +2379,189 @@ paths: summary: Delete measurements by external ID (iOS deletion-sync) tags: - Measurements + /api/workouts/batch: + post: + description: Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up + to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a + 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). + Per-entry status (`inserted | duplicate | skipped`) lets the iOS sync cursor checkpoint accurately. The request + body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with + `workout.batch.payload_too_large`. + requestBody: + content: + application/json: + schema: + properties: + workouts: + items: + properties: + avgHeartRate: + maximum: 300 + minimum: 20 + type: integer + elevationM: + maximum: 10000 + minimum: -500 + type: number + endedAt: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + externalId: + maxLength: 128 + type: string + externalSourceVersion: + maxLength: 64 + type: string + maxHeartRate: + maximum: 300 + minimum: 20 + type: integer + metadata: + additionalProperties: {} + propertyNames: + type: string + type: object + minHeartRate: + maximum: 300 + minimum: 20 + type: integer + pauseDurationSec: + maximum: 86400 + minimum: 0 + type: integer + route: + properties: + geometry: + properties: + coordinates: + items: + items: + type: number + prefixItems: + - maximum: 180 + minimum: -180 + type: number + - maximum: 90 + minimum: -90 + type: number + type: array + maxItems: 20000 + minItems: 2 + type: array + type: + const: LineString + type: string + required: + - type + - coordinates + type: object + sampleTimestamps: + items: + properties: + hr: + maximum: 300 + minimum: 20 + type: integer + speedMs: + maximum: 50 + minimum: 0 + type: number + t: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + required: + - t + type: object + type: array + required: + - geometry + type: object + source: + default: MANUAL + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + type: string + sportType: + enum: + - walking + - running + - cycling + - hiking + - swimming + - rowing + - elliptical + - stairClimber + - yoga + - mindAndBody + - strength + - hiit + - dance + - golf + - tennis + - basketball + - soccer + - crossTraining + - mixedCardio + - other + type: string + startedAt: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + stepCount: + maximum: 200000 + minimum: 0 + type: integer + totalDistanceM: + maximum: 1000000 + minimum: 0 + type: number + totalEnergyKcal: + maximum: 50000 + minimum: 0 + type: number + required: + - sportType + - startedAt + - endedAt + type: object + maxItems: 100 + minItems: 1 + type: array + required: + - workouts + type: object + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/BatchWorkoutsResponse" + description: Batch processed. + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Batch validation failed (over-cap, schema reject, or oversized route). + "401": *a1 + "413": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Request body exceeds the 5 MB ceiling. + "422": *a2 + "429": *a3 + summary: Typed workout batch ingest (v1.4.25 W16b) + tags: + - Measurements servers: - description: Production url: https://healthlog.bombeck.io diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index cc11ea9d0..36226dbe3 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -35,6 +35,7 @@ import { deviceTypeEnum, sourcePrioritySchema, } from "@/lib/validations/source-priority"; +import { createBatchWorkoutSchema } from "@/lib/validations/workout"; /** * Common envelopes — every HealthLog API response wraps payload in @@ -105,6 +106,12 @@ coachPrefsSchema.meta({ "Per-user Coach prompt-tuning preferences (v1.4.23 H4). All fields default to the legacy v1.4.22 behaviour when omitted.", }); +createBatchWorkoutSchema.meta({ + id: "CreateBatchWorkoutRequest", + description: + "v1.4.25 W16b — typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).", +}); + const coachMessageFeedbackBody = z .object({ rating: z.enum(["helpful", "unhelpful"]), @@ -280,6 +287,34 @@ const batchResponse = z }) .meta({ id: "AppleHealthBatchResponse" }); +// v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors +// the measurements batch shape but reports the `workouts` count rather +// than the entries count, and the `skipped` field is reserved (the +// Zod schema rejects malformed entries with a 400 before the per-entry +// pass, so today's responses always carry an empty array). +const workoutBatchEntryResult = z + .object({ + index: z.number().int().nonnegative(), + status: z.enum(["inserted", "duplicate", "skipped"]), + reason: z.string().optional(), + }) + .meta({ id: "WorkoutBatchEntryResult" }); + +const workoutBatchResponse = z + .object({ + processed: z.number().int().nonnegative(), + inserted: z.number().int().nonnegative(), + duplicates: z.number().int().nonnegative(), + skipped: z.array( + z.object({ + index: z.number().int().nonnegative(), + reason: z.string(), + }), + ), + entries: z.array(workoutBatchEntryResult), + }) + .meta({ id: "WorkoutBatchResponse" }); + const deleteByExternalIdsRequest = z .object({ externalIds: z.array(z.string().min(1).max(120)).min(0).max(500), @@ -524,6 +559,41 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/workouts/batch": { + post: { + tags: ["Measurements"], + summary: "Typed workout batch ingest (v1.4.25 W16b)", + description: + "Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | skipped`) lets the iOS sync cursor checkpoint accurately. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.", + requestBody: { + required: true, + content: { "application/json": { schema: createBatchWorkoutSchema } }, + }, + responses: { + "200": { + description: "Batch processed.", + content: { + "application/json": { + schema: dataEnvelope( + workoutBatchResponse, + "BatchWorkoutsResponse", + ), + }, + }, + }, + "400": { + description: + "Batch validation failed (over-cap, schema reject, or oversized route).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Request body exceeds the 5 MB ceiling.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/devices": { post: { tags: ["Devices"],