mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
chore(openapi): regenerate spec for /api/workouts/batch
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.
This commit is contained in:
1 parent
caf2371c77
commit
5a7d2526c9
2 files changed
+330
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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"],
|
||||
|
||||
Reference in new issue
Block a user