From f8688d5f8f29193a68e70bc54791e7ce134f3759 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:12:20 +0200 Subject: [PATCH] fix(api): v3 TypeScript sdk datetime validation --- .../src/models/schemas.ts | 2 +- .../tests/validation.spec.ts | 76 +++++++++++++++++++ .../typespec-typescript/src/zodBaseSchema.tsx | 8 +- .../templates/tests/validation.spec.ts | 76 +++++++++++++++++++ 4 files changed, 160 insertions(+), 2 deletions(-) diff --git a/api/spec/packages/aip-client-javascript/src/models/schemas.ts b/api/spec/packages/aip-client-javascript/src/models/schemas.ts index 03f5ba19d9..68182b4cde 100644 --- a/api/spec/packages/aip-client-javascript/src/models/schemas.ts +++ b/api/spec/packages/aip-client-javascript/src/models/schemas.ts @@ -6809,7 +6809,7 @@ export const ulidWire = z export const dateTimeWire = z .string() - .datetime() + .datetime({ offset: true }) .describe( '[RFC3339](https://tools.ietf.org/html/rfc3339) formatted date-time string in UTC.', diff --git a/api/spec/packages/aip-client-javascript/tests/validation.spec.ts b/api/spec/packages/aip-client-javascript/tests/validation.spec.ts index 6c1b437405..6a178f04b1 100644 --- a/api/spec/packages/aip-client-javascript/tests/validation.spec.ts +++ b/api/spec/packages/aip-client-javascript/tests/validation.spec.ts @@ -295,3 +295,79 @@ describe('nullable cursor response validation', () => { expect(result.value?.meta.page.previous).toBe(42) }) }) + +// RFC 3339 permits a numeric UTC offset, not just `Z`, and the API emits them. +describe('strict validation accepts RFC 3339 numeric UTC offsets', () => { + it('accepts meter query rows whose from/to carry a numeric offset', async () => { + fetchMock.route( + '*', + json({ + data: [ + { + value: '10', + from: '2024-01-01T00:00:00+02:00', + to: '2024-01-02T00:00:00-07:00', + dimensions: { subject: 'customer-1' }, + }, + ], + }), + ) + + const result = await funcs.queryMeter(client(true), { meterId, body: {} }) + + expect(result.error).toBeUndefined() + expect(result.ok).toBe(true) + expect(result.value?.data[0]?.value).toBe('10') + // the offset is a real instant, not a UTC wall-clock reading + expect(result.value?.data[0]?.from?.toISOString()).toBe( + '2023-12-31T22:00:00.000Z', + ) + expect(result.value?.data[0]?.to?.toISOString()).toBe( + '2024-01-02T07:00:00.000Z', + ) + }) + + it('still accepts a plain Z timestamp', async () => { + fetchMock.route( + '*', + json({ + data: [ + { + value: '10', + from: '2024-01-01T00:00:00Z', + to: '2024-01-02T00:00:00Z', + dimensions: { subject: 'customer-1' }, + }, + ], + }), + ) + + const result = await funcs.queryMeter(client(true), { meterId, body: {} }) + + expect(result.ok).toBe(true) + expect(result.value?.data[0]?.from?.toISOString()).toBe( + '2024-01-01T00:00:00.000Z', + ) + }) + + it('still rejects a genuinely malformed timestamp', async () => { + fetchMock.route( + '*', + json({ + data: [ + { + value: '10', + from: 'not-a-timestamp', + to: '2024-01-02T00:00:00Z', + dimensions: { subject: 'customer-1' }, + }, + ], + }), + ) + + const result = await funcs.queryMeter(client(true), { meterId, body: {} }) + + expect(result.ok).toBe(false) + expect(result.error).toBeInstanceOf(ValidationError) + }) +}) diff --git a/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx b/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx index b02103fcc4..ac75f82553 100644 --- a/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx +++ b/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx @@ -216,10 +216,16 @@ function scalarBaseType($: Typekit, type: Scalar) { * pass types them `z.date()` (the SDK surface takes and returns `Date`; the * runtime wire mapper converts at the request/response boundary), while the * wire pass keeps the RFC 3339 string the JSON payload actually carries. + * + * `offset: true` because RFC 3339 permits a numeric UTC offset, not just `Z`, + * and the API emits them; zod's default rejects everything but `Z`. */ function dateTimeBaseType() { if (useWireMode()) { - return zodMemberExpr(callPart('string'), callPart('datetime')) + return zodMemberExpr( + callPart('string'), + callPart('datetime', '{ offset: true }'), + ) } return zodMemberExpr(callPart('date')) } diff --git a/api/spec/packages/typespec-typescript/templates/tests/validation.spec.ts b/api/spec/packages/typespec-typescript/templates/tests/validation.spec.ts index 006df924e4..c33f4feb0b 100644 --- a/api/spec/packages/typespec-typescript/templates/tests/validation.spec.ts +++ b/api/spec/packages/typespec-typescript/templates/tests/validation.spec.ts @@ -293,3 +293,79 @@ describe('nullable cursor response validation', () => { expect(result.value?.meta.page.previous).toBe(42) }) }) + +// RFC 3339 permits a numeric UTC offset, not just `Z`, and the API emits them. +describe('strict validation accepts RFC 3339 numeric UTC offsets', () => { + it('accepts meter query rows whose from/to carry a numeric offset', async () => { + fetchMock.route( + '*', + json({ + data: [ + { + value: '10', + from: '2024-01-01T00:00:00+02:00', + to: '2024-01-02T00:00:00-07:00', + dimensions: { subject: 'customer-1' }, + }, + ], + }), + ) + + const result = await funcs.queryMeter(client(true), { meterId, body: {} }) + + expect(result.error).toBeUndefined() + expect(result.ok).toBe(true) + expect(result.value?.data[0]?.value).toBe('10') + // the offset is a real instant, not a UTC wall-clock reading + expect(result.value?.data[0]?.from?.toISOString()).toBe( + '2023-12-31T22:00:00.000Z', + ) + expect(result.value?.data[0]?.to?.toISOString()).toBe( + '2024-01-02T07:00:00.000Z', + ) + }) + + it('still accepts a plain Z timestamp', async () => { + fetchMock.route( + '*', + json({ + data: [ + { + value: '10', + from: '2024-01-01T00:00:00Z', + to: '2024-01-02T00:00:00Z', + dimensions: { subject: 'customer-1' }, + }, + ], + }), + ) + + const result = await funcs.queryMeter(client(true), { meterId, body: {} }) + + expect(result.ok).toBe(true) + expect(result.value?.data[0]?.from?.toISOString()).toBe( + '2024-01-01T00:00:00.000Z', + ) + }) + + it('still rejects a genuinely malformed timestamp', async () => { + fetchMock.route( + '*', + json({ + data: [ + { + value: '10', + from: 'not-a-timestamp', + to: '2024-01-02T00:00:00Z', + dimensions: { subject: 'customer-1' }, + }, + ], + }), + ) + + const result = await funcs.queryMeter(client(true), { meterId, body: {} }) + + expect(result.ok).toBe(false) + expect(result.error).toBeInstanceOf(ValidationError) + }) +})