From f7051ee15f452fdb5d27eaf442044652df7379b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:47:08 +0200 Subject: [PATCH] fix(openapi): declare what the measurement list and create answers carry The sleep list rows of GET /api/measurements carry the main night's stage minutes, time in bed, time awake and awakenings, and the aggregate buckets carry the bucket's lowest and highest reading; none of these were in the contract. The create answer's union tried the plain row before the named sleep-stage result, so a contract check read a named-stage answer as a plain row and dropped its status. The responses are unchanged; the contract now says what they hold. --- docs/api/openapi.yaml | 90 +++++++++++++++++++++++++- src/lib/openapi/routes/measurements.ts | 50 +++++++++++++- 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 574eeb12f..3c83adf26 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -48307,6 +48307,32 @@ components: description: Time asleep across those naps, in minutes. type: number minimum: 0 + sleepStages: + description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of + that stage's spans; empty on a day of naps alone. + type: object + propertyNames: + type: string + additionalProperties: + type: number + minimum: 0 + inBedMinutes: + description: The main night's time in bed in minutes, or null when no in-bed span was recorded. + anyOf: + - type: number + minimum: 0 + - type: "null" + awakeMinutes: + description: The main night's time awake in bed in minutes, or null when no awake span was recorded. + anyOf: + - type: number + minimum: 0 + - type: "null" + awakenings: + description: Awake spans between the main night's first and last time asleep. + type: integer + minimum: 0 + maximum: 9007199254740991 required: - id - type @@ -49077,6 +49103,32 @@ components: description: Time asleep across those naps, in minutes. type: number minimum: 0 + sleepStages: + description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of + that stage's spans; empty on a day of naps alone. + type: object + propertyNames: + type: string + additionalProperties: + type: number + minimum: 0 + inBedMinutes: + description: The main night's time in bed in minutes, or null when no in-bed span was recorded. + anyOf: + - type: number + minimum: 0 + - type: "null" + awakeMinutes: + description: The main night's time awake in bed in minutes, or null when no awake span was recorded. + anyOf: + - type: number + minimum: 0 + - type: "null" + awakenings: + description: Awake spans between the main night's first and last time asleep. + type: integer + minimum: 0 + maximum: 9007199254740991 required: - id - type @@ -49105,6 +49157,16 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + minValue: + description: The lowest reading in the bucket; absent on a summed (cumulative) type. + type: + - number + - "null" + maxValue: + description: The highest reading in the bucket; absent on a summed (cumulative) type. + type: + - number + - "null" unit: type: string required: @@ -49282,6 +49344,32 @@ components: description: Time asleep across those naps, in minutes. type: number minimum: 0 + sleepStages: + description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of + that stage's spans; empty on a day of naps alone. + type: object + propertyNames: + type: string + additionalProperties: + type: number + minimum: 0 + inBedMinutes: + description: The main night's time in bed in minutes, or null when no in-bed span was recorded. + anyOf: + - type: number + minimum: 0 + - type: "null" + awakeMinutes: + description: The main night's time awake in bed in minutes, or null when no awake span was recorded. + anyOf: + - type: number + minimum: 0 + - type: "null" + awakenings: + description: Awake spans between the main night's first and last time asleep. + type: integer + minimum: 0 + maximum: 9007199254740991 status: type: string enum: @@ -49306,11 +49394,11 @@ components: properties: data: anyOf: + - $ref: "#/components/schemas/NamedSleepStageMeasurement" - $ref: "#/components/schemas/MeasurementResource" - type: array items: $ref: "#/components/schemas/MeasurementResource" - - $ref: "#/components/schemas/NamedSleepStageMeasurement" error: type: "null" meta: diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 88706b03a..8415c13b8 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -356,6 +356,36 @@ export const measurementResource = z .nonnegative() .optional() .describe("Time asleep across those naps, in minutes."), + sleepStages: z + .record(z.string(), z.number().nonnegative()) + .optional() + .describe( + "The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of that stage's spans; empty on a day of naps alone.", + ), + inBedMinutes: z + .number() + .nonnegative() + .nullable() + .optional() + .describe( + "The main night's time in bed in minutes, or null when no in-bed span was recorded.", + ), + awakeMinutes: z + .number() + .nonnegative() + .nullable() + .optional() + .describe( + "The main night's time awake in bed in minutes, or null when no awake span was recorded.", + ), + awakenings: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Awake spans between the main night's first and last time asleep.", + ), }) .meta({ id: "MeasurementResource", @@ -388,6 +418,20 @@ const aggregatedMeasurementBucket = z value: z.number().describe("The bucket's aggregated value."), measuredAt: isoDateTime().describe("Bucket start."), count: z.number().int().nonnegative().optional(), + minValue: z + .number() + .nullable() + .optional() + .describe( + "The lowest reading in the bucket; absent on a summed (cumulative) type.", + ), + maxValue: z + .number() + .nullable() + .optional() + .describe( + "The highest reading in the bucket; absent on a summed (cumulative) type.", + ), unit: z.string().optional(), }) .meta({ @@ -895,11 +939,15 @@ export const measurementPaths: NonNullable = { "Created. A single resource for the object body; an array of resources, in request order, for the array body; for a named sleep-stage body, the stored row with `status: inserted`.", content: { "application/json": { + // The named-stage result first: it requires `status`, so a + // plain row falls through to `measurementResource`, while a + // union that tried the plain row first would read a named-stage + // answer as a plain row and drop its `status`. schema: dataEnvelope( z.union([ + namedSleepStageResult, measurementResource, z.array(measurementResource), - namedSleepStageResult, ]), "CreateMeasurementResponse", ),