diff --git a/api/spec/packages/aip-client-javascript/src/core.ts b/api/spec/packages/aip-client-javascript/src/core.ts index 37464ed34d..0a6efc7512 100644 --- a/api/spec/packages/aip-client-javascript/src/core.ts +++ b/api/spec/packages/aip-client-javascript/src/core.ts @@ -29,10 +29,15 @@ export class Client { beforeRequest: [ ...(options.hooks?.beforeRequest ?? []), async ({ request }) => { - // Browsers treat User-Agent as a forbidden header and silently drop - // it; that's fine here, this is only observable server-side (e.g. - // request logs) and Node/server callers get it. - if (!request.headers.has('User-Agent')) { + // User-Agent is settable on the web but is not CORS-safelisted, so + // setting it would preflight otherwise-simple cross-origin requests. + // Gate on a positive Node check, not `window`: workers are also + // CORS-bound yet have no `window`. + if ( + typeof process !== 'undefined' && + process.versions?.node != null && + !request.headers.has('User-Agent') + ) { request.headers.set('User-Agent', `openmeter-node/${SDK_VERSION}`) } 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 26711babd6..03f5ba19d9 100644 --- a/api/spec/packages/aip-client-javascript/src/models/schemas.ts +++ b/api/spec/packages/aip-client-javascript/src/models/schemas.ts @@ -163,10 +163,8 @@ export const cursorMetaPage = z .object({ first: z.string().optional().describe('URI to the first page.'), last: z.string().optional().describe('URI to the last page.'), - next: z.union([z.string(), z.null()]).describe('URI to the next page.'), - previous: z - .union([z.string(), z.null()]) - .describe('URI to the previous page.'), + next: z.string().nullable().describe('URI to the next page.'), + previous: z.string().nullable().describe('URI to the previous page.'), size: z.number().int().describe('Requested page size.'), }) .describe('Cursor pagination metadata.') @@ -1829,14 +1827,16 @@ export const event = z 'Contains a value describing the type of event related to the originating occurrence.', ), datacontenttype: z - .union([z.literal('application/json'), z.null()]) + .literal('application/json') + .nullable() .optional() .describe( 'Content type of the CloudEvents data value. Only the value "application/json" is allowed over HTTP.', ), dataschema: z - .union([z.string(), z.null()]) + .string() + .nullable() .optional() .describe('Identifies the schema that data adheres to.'), subject: z @@ -1846,15 +1846,16 @@ export const event = z .describe( 'Describes the subject of the event in the context of the event producer (identified by source).', ), - time: z - .union([dateTime, z.null()]) + time: dateTime + .nullable() .optional() .describe( 'Timestamp of when the occurrence happened. Must adhere to RFC 3339.', ), data: z - .union([z.record(z.string(), z.unknown()), z.null()]) + .record(z.string(), z.unknown()) + .nullable() .optional() .describe( @@ -1978,8 +1979,8 @@ export const createCostBasisRequest = z export const featureCostQueryRow = z .object({ usage: numeric, - cost: z - .union([numeric, z.null()]) + cost: numeric + .nullable() .describe( 'The computed cost amount (usage × unit cost). Null when pricing is not available for the given combination of dimensions.', @@ -4797,8 +4798,8 @@ export const createFeatureRequest = z export const updateFeatureRequest = z .object({ - unitCost: z - .union([featureUnitCost, z.null()]) + unitCost: featureUnitCost + .nullable() .optional() .describe( @@ -6844,10 +6845,8 @@ export const cursorMetaPageWire = z .strictObject({ first: z.string().optional().describe('URI to the first page.'), last: z.string().optional().describe('URI to the last page.'), - next: z.union([z.string(), z.null()]).describe('URI to the next page.'), - previous: z - .union([z.string(), z.null()]) - .describe('URI to the previous page.'), + next: z.string().nullable().describe('URI to the next page.'), + previous: z.string().nullable().describe('URI to the previous page.'), size: z.number().int().describe('Requested page size.'), }) .describe('Cursor pagination metadata.') @@ -8503,14 +8502,16 @@ export const eventWire = z 'Contains a value describing the type of event related to the originating occurrence.', ), datacontenttype: z - .union([z.literal('application/json'), z.null()]) + .literal('application/json') + .nullable() .optional() .describe( 'Content type of the CloudEvents data value. Only the value "application/json" is allowed over HTTP.', ), dataschema: z - .union([z.string(), z.null()]) + .string() + .nullable() .optional() .describe('Identifies the schema that data adheres to.'), subject: z @@ -8520,15 +8521,16 @@ export const eventWire = z .describe( 'Describes the subject of the event in the context of the event producer (identified by source).', ), - time: z - .union([dateTimeWire, z.null()]) + time: dateTimeWire + .nullable() .optional() .describe( 'Timestamp of when the occurrence happened. Must adhere to RFC 3339.', ), data: z - .union([z.record(z.string(), z.unknown()), z.null()]) + .record(z.string(), z.unknown()) + .nullable() .optional() .describe( @@ -8652,8 +8654,8 @@ export const createCostBasisRequestWire = z export const featureCostQueryRowWire = z .strictObject({ usage: numericWire, - cost: z - .union([numericWire, z.null()]) + cost: numericWire + .nullable() .describe( 'The computed cost amount (usage × unit cost). Null when pricing is not available for the given combination of dimensions.', @@ -11460,8 +11462,8 @@ export const createFeatureRequestWire = z export const updateFeatureRequestWire = z .strictObject({ - unit_cost: z - .union([featureUnitCostWire, z.null()]) + unit_cost: featureUnitCostWire + .nullable() .optional() .describe( diff --git a/api/spec/packages/aip-client-javascript/tests/client.spec.ts b/api/spec/packages/aip-client-javascript/tests/client.spec.ts index 76118fd80c..260cbbc599 100644 --- a/api/spec/packages/aip-client-javascript/tests/client.spec.ts +++ b/api/spec/packages/aip-client-javascript/tests/client.spec.ts @@ -1,7 +1,7 @@ // Code generated by @openmeter/typespec-typescript. DO NOT EDIT. import fetchMock from '@fetch-mock/vitest' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OpenMeter, ServerList } from '../src/index.js' import { SDK_VERSION } from '../src/lib/version.js' @@ -130,6 +130,10 @@ describe('option clobbering is prevented', () => { }) describe('SDK telemetry headers', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + it('sets a default User-Agent header', async () => { mockMeter() const sdk = new OpenMeter({ @@ -152,6 +156,48 @@ describe('SDK telemetry headers', () => { await sdk.meters.get({ meterId: 'm' }) expect(lastUserAgent()).toBe('custom-agent/1.0') }) + + it('does not set a default User-Agent in browsers', async () => { + vi.stubGlobal('window', {}) + vi.stubGlobal('process', undefined) + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUserAgent()).toBeNull() + }) + + // Workers are CORS-bound but have no `window`, so a `window`-based gate would + // leak the header here and preflight the request. + it('does not set a default User-Agent in workers', async () => { + vi.stubGlobal('self', {}) + vi.stubGlobal('process', undefined) + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUserAgent()).toBeNull() + }) + + it('honors a caller-provided User-Agent in browsers', async () => { + vi.stubGlobal('window', {}) + vi.stubGlobal('process', undefined) + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + headers: { 'User-Agent': 'custom-agent/1.0' }, + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUserAgent()).toBe('custom-agent/1.0') + }) }) describe('namespace composition', () => { diff --git a/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx b/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx index 395c2f909e..b02103fcc4 100644 --- a/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx +++ b/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx @@ -384,6 +384,42 @@ function unionBaseType(type: Union) { const discriminated = $.union.getDiscriminatedUnion(type) + if ($.union.isExpression(type)) { + const variants = Array.from(type.variants.values()) + const nonNullVariants = variants.filter( + (variant) => + variant.type.kind !== 'Intrinsic' || variant.type.name !== 'null', + ) + + if ( + nonNullVariants.length > 0 && + nonNullVariants.length < variants.length + ) { + const inner = + nonNullVariants.length === 1 ? ( + + ) : ( + zodMemberExpr( + callPart( + 'union', + + + {(variant) => } + + , + ), + ) + ) + + return ( + + {inner} + {callPart('nullable')} + + ) + } + } + if ($.union.isExpression(type) || !discriminated) { return zodMemberExpr( callPart( diff --git a/api/spec/packages/typespec-typescript/templates/runtime/core.ts b/api/spec/packages/typespec-typescript/templates/runtime/core.ts index b02882640f..88c9b8c163 100644 --- a/api/spec/packages/typespec-typescript/templates/runtime/core.ts +++ b/api/spec/packages/typespec-typescript/templates/runtime/core.ts @@ -27,10 +27,15 @@ export class Client { beforeRequest: [ ...(options.hooks?.beforeRequest ?? []), async ({ request }) => { - // Browsers treat User-Agent as a forbidden header and silently drop - // it; that's fine here, this is only observable server-side (e.g. - // request logs) and Node/server callers get it. - if (!request.headers.has('User-Agent')) { + // User-Agent is settable on the web but is not CORS-safelisted, so + // setting it would preflight otherwise-simple cross-origin requests. + // Gate on a positive Node check, not `window`: workers are also + // CORS-bound yet have no `window`. + if ( + typeof process !== 'undefined' && + process.versions?.node != null && + !request.headers.has('User-Agent') + ) { request.headers.set('User-Agent', `openmeter-node/${SDK_VERSION}`) } diff --git a/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts b/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts index 8047bd35a7..3d41d1ece9 100644 --- a/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts +++ b/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts @@ -1,5 +1,5 @@ import fetchMock from '@fetch-mock/vitest' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OpenMeter, ServerList } from '../src/index.js' import { SDK_VERSION } from '../src/lib/version.js' @@ -128,6 +128,10 @@ describe('option clobbering is prevented', () => { }) describe('SDK telemetry headers', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + it('sets a default User-Agent header', async () => { mockMeter() const sdk = new OpenMeter({ @@ -150,6 +154,48 @@ describe('SDK telemetry headers', () => { await sdk.meters.get({ meterId: 'm' }) expect(lastUserAgent()).toBe('custom-agent/1.0') }) + + it('does not set a default User-Agent in browsers', async () => { + vi.stubGlobal('window', {}) + vi.stubGlobal('process', undefined) + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUserAgent()).toBeNull() + }) + + // Workers are CORS-bound but have no `window`, so a `window`-based gate would + // leak the header here and preflight the request. + it('does not set a default User-Agent in workers', async () => { + vi.stubGlobal('self', {}) + vi.stubGlobal('process', undefined) + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUserAgent()).toBeNull() + }) + + it('honors a caller-provided User-Agent in browsers', async () => { + vi.stubGlobal('window', {}) + vi.stubGlobal('process', undefined) + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + headers: { 'User-Agent': 'custom-agent/1.0' }, + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUserAgent()).toBe('custom-agent/1.0') + }) }) describe('namespace composition', () => { diff --git a/api/spec/packages/typespec-typescript/test/nullable-union.test.ts b/api/spec/packages/typespec-typescript/test/nullable-union.test.ts new file mode 100644 index 0000000000..24c79d6006 --- /dev/null +++ b/api/spec/packages/typespec-typescript/test/nullable-union.test.ts @@ -0,0 +1,143 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { fromWire, toWire } from '../src/runtime/wire.js' +import { EmitterTester } from './emit.js' + +const FIXTURE = ` +import "@typespec/http"; +import "@typespec/openapi"; + +using TypeSpec.Http; +using TypeSpec.OpenAPI; + +namespace Widgets { + model FlatCost { + type: "flat"; + amount: int32; + } + + model DynamicCost { + type: "dynamic"; + provider_property: string; + model_property: string; + } + + @discriminated(#{ envelope: "none", discriminatorPropertyName: "type" }) + union Cost { + flat: FlatCost, + dynamic: DynamicCost, + } + + model Payload { + nullable_cost?: Cost | null; + multi: "alpha" | 42 | null; + null_only: null; + plain_union: "alpha" | 42; + } + + interface WidgetOperations { + @post + @operationId("create-widget") + create(@body body: Payload): Payload; + } +} + +@service(#{ title: "Test API" }) +namespace Api { + @route("/widgets") + interface WidgetEndpoints extends Widgets.WidgetOperations {} +} +` + +describe('nullable union emission', () => { + let schemas: string + + beforeAll(async () => { + const [result, diagnostics] = + await EmitterTester.compileAndDiagnose(FIXTURE) + expect( + diagnostics.filter((d) => d.severity === 'error'), + 'fixture must compile without errors', + ).toEqual([]) + schemas = result.outputs['src/models/schemas.ts']! + }) + + it('preserves a nullable discriminated union in public and wire schemas', () => { + expect(schemas).toMatch(/nullableCost: cost\.nullable\(\)\.optional\(\)/) + expect(schemas).toMatch( + /nullable_cost: costWire\.nullable\(\)\.optional\(\)/, + ) + expect(schemas).not.toMatch( + /z\.union\(\[\s*cost(?:Wire)?,\s*z\.null\(\)\s*\]\)/, + ) + }) + + it('keeps every non-null variant in a multi-variant nullable union', () => { + expect( + schemas.match( + /multi: z\s*\.union\(\[z\.literal\("alpha"\), z\.literal\(42\)\]\)\s*\.nullable\(\)/g, + ), + ).toHaveLength(2) + }) + + it('keeps bare null intrinsic and non-null union emission unchanged', () => { + expect(schemas.match(/nullOnly: z\.null\(\)/g)).toHaveLength(1) + expect(schemas.match(/null_only: z\.null\(\)/g)).toHaveLength(1) + expect(schemas).toMatch( + /plainUnion: z\s*\.union\(\[z\.literal\("alpha"\), z\.literal\(42\)\]\)(?!\s*\.nullable\(\))/, + ) + expect(schemas).toMatch( + /plain_union: z\s*\.union\(\[z\.literal\("alpha"\), z\.literal\(42\)\]\)(?!\s*\.nullable\(\))/, + ) + }) + + it('round-trips renamed keys through a nullable discriminated union', () => { + const cost = z.discriminatedUnion('type', [ + z.object({ type: z.literal('flat'), amount: z.number() }), + z.object({ + type: z.literal('dynamic'), + providerProperty: z.string(), + modelProperty: z.string(), + }), + ]) + const body = z.object({ nullableCost: cost.nullable().optional() }) + const bodyWire = z.strictObject({ + nullable_cost: z + .discriminatedUnion('type', [ + z.strictObject({ type: z.literal('flat'), amount: z.number() }), + z.strictObject({ + type: z.literal('dynamic'), + provider_property: z.string(), + model_property: z.string(), + }), + ]) + .nullable() + .optional(), + }) + const input = { + nullableCost: { + type: 'dynamic' as const, + providerProperty: 'provider', + modelProperty: 'model', + }, + } + + const wire = toWire(input, body) + + expect(body.safeParse(input).success).toBe(true) + expect(wire).toEqual({ + nullable_cost: { + type: 'dynamic', + provider_property: 'provider', + model_property: 'model', + }, + }) + expect(bodyWire.safeParse(wire).success).toBe(true) + expect(fromWire(wire, body)).toEqual(input) + + const nullWire = toWire({ nullableCost: null }, body) + expect(nullWire).toEqual({ nullable_cost: null }) + expect(bodyWire.safeParse(nullWire).success).toBe(true) + expect(fromWire(nullWire, body)).toEqual({ nullableCost: null }) + }) +})