From ecfa5d6571c771dd373e0b7108022826e3d32339 Mon Sep 17 00:00:00 2001 From: Peter Turi Date: Tue, 27 Oct 2026 14:42:52 +0100 Subject: [PATCH 1/2] fix(sdk): preserve optional TypeScript request bodies --- .../src/funcs/customers.ts | 4 +- .../src/funcs/features.ts | 2 +- .../src/models/operations/customers.ts | 4 +- .../src/models/operations/features.ts | 2 +- .../tests/validation.spec.ts | 64 ++++++++++++++++++- .../typespec-typescript/src/request-types.ts | 4 +- .../typespec-typescript/src/sdk-files.ts | 12 +++- .../typespec-typescript/src/sdk-operations.ts | 4 ++ .../templates/tests/validation.spec.ts | 64 ++++++++++++++++++- .../test/optional-body.test.ts | 63 ++++++++++++++++++ 10 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 api/spec/packages/typespec-typescript/test/optional-body.test.ts diff --git a/api/spec/packages/aip-client-javascript/src/funcs/customers.ts b/api/spec/packages/aip-client-javascript/src/funcs/customers.ts index 30931674f4..8e12027bf9 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/customers.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/customers.ts @@ -893,7 +893,7 @@ export function resetCustomerEntitlementUsage( return encodeURIComponent(String(pathParams.entitlementId)) })()}/reset` const body = toWire(req.body, schemas.resetCustomerEntitlementUsageBody) - if (client._options.validate) { + if (client._options.validate && body !== undefined) { assertValid(schemas.resetCustomerEntitlementUsageBodyWire, body) } await http(client).post(path, { ...options, json: body }) @@ -1367,7 +1367,7 @@ export function voidCreditGrant( return encodeURIComponent(String(pathParams.creditGrantId)) })()}/void` const body = toWire(req.body, schemas.voidCreditGrantBody) - if (client._options.validate) { + if (client._options.validate && body !== undefined) { assertValid(schemas.voidCreditGrantBodyWire, body) } return http(client) diff --git a/api/spec/packages/aip-client-javascript/src/funcs/features.ts b/api/spec/packages/aip-client-javascript/src/funcs/features.ts index 1817610c6f..dc2e8af46c 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/features.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/features.ts @@ -241,7 +241,7 @@ export function queryFeatureCost( return encodeURIComponent(String(pathParams.featureId)) })()}/cost/query` const body = toWire(req.body, schemas.queryFeatureCostBody) - if (client._options.validate) { + if (client._options.validate && body !== undefined) { assertValid(schemas.queryFeatureCostBodyWire, body) } return http(client) diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/customers.ts b/api/spec/packages/aip-client-javascript/src/models/operations/customers.ts index 950c180d3f..5ee8491074 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/customers.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/customers.ts @@ -204,7 +204,7 @@ export type ListCustomerEntitlementsResponse = EntitlementPagePaginatedResponse export type ResetCustomerEntitlementUsageRequest = AcceptDateStrings<{ customerId: string entitlementId: string - body: ResetCustomerEntitlementUsageRequestInput + body?: ResetCustomerEntitlementUsageRequestInput }> export type ResetCustomerEntitlementUsageResponse = void @@ -306,7 +306,7 @@ export type CreateCreditAdjustmentResponse = CreditAdjustment export type VoidCreditGrantRequest = AcceptDateStrings<{ customerId: string creditGrantId: string - body: VoidCreditGrantRequestInput + body?: VoidCreditGrantRequestInput }> export type VoidCreditGrantResponse = CreditGrant diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/features.ts b/api/spec/packages/aip-client-javascript/src/models/operations/features.ts index 0e7e0637af..86fb813f0d 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/features.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/features.ts @@ -62,6 +62,6 @@ export type DeleteFeatureResponse = void export type QueryFeatureCostRequest = AcceptDateStrings<{ featureId: string - body: MeterQueryRequestInput + body?: MeterQueryRequestInput }> export type QueryFeatureCostResponse = FeatureCostQueryResult 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 6a178f04b1..77850fc1bb 100644 --- a/api/spec/packages/aip-client-javascript/tests/validation.spec.ts +++ b/api/spec/packages/aip-client-javascript/tests/validation.spec.ts @@ -2,7 +2,7 @@ import fetchMock from '@fetch-mock/vitest' import { beforeEach, describe, expect, it } from 'vitest' -import { Client, funcs, ValidationError } from '../src/index.js' +import { Client, Customers, funcs, ValidationError } from '../src/index.js' import { toWire } from '../src/lib/wire.js' import * as schemas from '../src/models/schemas.js' @@ -43,6 +43,68 @@ function json(body: unknown) { } describe('strict request validation uses the effective JSON payload', () => { + it.each([true, false])( + 'resets entitlement usage without an optional body (validate=%s)', + async (validate) => { + fetchMock.route('*', 204) + + await new Customers(client(validate)).entitlements.resetUsage({ + customerId, + entitlementId: meterId, + }) + + expect(fetchMock.callHistory.calls()).toHaveLength(1) + const request = fetchMock.callHistory.lastCall()!.args[0] as Request + expect(request.body).toBeNull() + }, + ) + + it('maps and validates a provided optional reset body', async () => { + let sentBody: unknown + fetchMock.route('*', ({ options }) => { + sentBody = JSON.parse(options!.body as string) + return 204 + }) + + await new Customers(client(true)).entitlements.resetUsage({ + customerId, + entitlementId: meterId, + body: { + effectiveAt: new Date('2026-01-01T00:00:00Z'), + retainAnchor: true, + }, + }) + + expect(sentBody).toEqual({ + effective_at: '2026-01-01T00:00:00.000Z', + retain_anchor: true, + }) + }) + + it('rejects an invalid provided optional reset body before sending', async () => { + fetchMock.route('*', 204) + + await expect( + new Customers(client(true)).entitlements.resetUsage({ + customerId, + entitlementId: meterId, + body: { retainAnchor: 'invalid' as never }, + }), + ).rejects.toBeInstanceOf(ValidationError) + + expect(fetchMock.callHistory.calls()).toHaveLength(0) + }) + + it('still rejects an omitted required request body before sending', async () => { + fetchMock.route('*', json({})) + + const result = await funcs.createMeter(client(true), undefined as never) + + expect(result.ok).toBe(false) + expect(result.error).toBeInstanceOf(ValidationError) + expect(fetchMock.callHistory.calls()).toHaveLength(0) + }) + it('omits explicit undefined object properties and record entries in toWire', () => { const wire = toWire( { diff --git a/api/spec/packages/typespec-typescript/src/request-types.ts b/api/spec/packages/typespec-typescript/src/request-types.ts index 0916487066..c3abe39287 100644 --- a/api/spec/packages/typespec-typescript/src/request-types.ts +++ b/api/spec/packages/typespec-typescript/src/request-types.ts @@ -81,13 +81,13 @@ function requestDecl(op: SdkOperation, bodyRef: string): string { if (op.hasBody) { if (!hasPath && !hasQuery) { - return `${decl} AcceptDateStrings<${bodyRef}>` + return `${decl} AcceptDateStrings<${bodyRef}>${op.bodyOptional ? ' | undefined' : ''}` } const queryPart = hasQuery ? ` & ${queryRef}` : '' const pathLine = hasPath ? `${op.pathParams.map(pathField).join('\n')}\n` : '' - return `${decl} AcceptDateStrings<{\n${pathLine} body: ${bodyRef}\n}${queryPart}>` + return `${decl} AcceptDateStrings<{\n${pathLine} body${op.bodyOptional ? '?' : ''}: ${bodyRef}\n}${queryPart}>` } if (hasQuery) { return hasPath diff --git a/api/spec/packages/typespec-typescript/src/sdk-files.ts b/api/spec/packages/typespec-typescript/src/sdk-files.ts index 3f37d5301c..ab902a137a 100644 --- a/api/spec/packages/typespec-typescript/src/sdk-files.ts +++ b/api/spec/packages/typespec-typescript/src/sdk-files.ts @@ -49,7 +49,9 @@ function funcBody(op: SdkOperation): string { const reqParam = hasQuery && !op.hasBody && !hasPath ? `req: ${reqType} = {}` - : `req: ${reqType}` + : op.bodyOptional && !hasPath && !hasQuery + ? `req?: ${reqType}` + : `req: ${reqType}` const url = hasPath ? pathExpr(op, 'pathParams') @@ -121,7 +123,7 @@ function funcBody(op: SdkOperation): string { const prepareBody = op.hasBody ? [ ` const body = toWire(${bodyValue}, schemas.${op.funcName}Body)`, - ` if (client._options.validate) {`, + ` if (client._options.validate${op.bodyOptional ? ' && body !== undefined' : ''}) {`, ` assertValid(schemas.${op.funcName}BodyWire, body)`, ` }`, ] @@ -327,7 +329,11 @@ interface FacadeNode { } function reqOptionalFor(op: SdkOperation): boolean { - return op.queryParams.length > 0 && !op.hasBody && op.pathParams.length === 0 + return ( + op.pathParams.length === 0 && + ((!op.hasBody && op.queryParams.length > 0) || + (op.bodyOptional && op.queryParams.length === 0)) + ) } function emitMethod(op: SdkOperation): string { diff --git a/api/spec/packages/typespec-typescript/src/sdk-operations.ts b/api/spec/packages/typespec-typescript/src/sdk-operations.ts index e49c305dfc..67ccc3aa73 100644 --- a/api/spec/packages/typespec-typescript/src/sdk-operations.ts +++ b/api/spec/packages/typespec-typescript/src/sdk-operations.ts @@ -24,6 +24,7 @@ export interface SdkOperation { queryParams: string[] queryCodecs: SdkQueryCodec[] hasBody: boolean + bodyOptional: boolean hasResponse: boolean /** Documented interface name for the success body, when it is a named model. */ responseInterface?: string @@ -187,6 +188,9 @@ export function sdkOperation( nestPath, hasBody: httpOp.parameters.body?.type !== undefined || bodyOverrides.has(base), + bodyOptional: + !bodyOverrides.has(base) && + (httpOp.parameters.body?.property?.optional ?? false), hasResponse: responseBody !== undefined, requestBodyInterface, responseInterface, 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 c33f4feb0b..e7d7b62ea0 100644 --- a/api/spec/packages/typespec-typescript/templates/tests/validation.spec.ts +++ b/api/spec/packages/typespec-typescript/templates/tests/validation.spec.ts @@ -1,6 +1,6 @@ import fetchMock from '@fetch-mock/vitest' import { beforeEach, describe, expect, it } from 'vitest' -import { Client, funcs, ValidationError } from '../src/index.js' +import { Client, Customers, funcs, ValidationError } from '../src/index.js' import { toWire } from '../src/lib/wire.js' import * as schemas from '../src/models/schemas.js' @@ -41,6 +41,68 @@ function json(body: unknown) { } describe('strict request validation uses the effective JSON payload', () => { + it.each([true, false])( + 'resets entitlement usage without an optional body (validate=%s)', + async (validate) => { + fetchMock.route('*', 204) + + await new Customers(client(validate)).entitlements.resetUsage({ + customerId, + entitlementId: meterId, + }) + + expect(fetchMock.callHistory.calls()).toHaveLength(1) + const request = fetchMock.callHistory.lastCall()!.args[0] as Request + expect(request.body).toBeNull() + }, + ) + + it('maps and validates a provided optional reset body', async () => { + let sentBody: unknown + fetchMock.route('*', ({ options }) => { + sentBody = JSON.parse(options!.body as string) + return 204 + }) + + await new Customers(client(true)).entitlements.resetUsage({ + customerId, + entitlementId: meterId, + body: { + effectiveAt: new Date('2026-01-01T00:00:00Z'), + retainAnchor: true, + }, + }) + + expect(sentBody).toEqual({ + effective_at: '2026-01-01T00:00:00.000Z', + retain_anchor: true, + }) + }) + + it('rejects an invalid provided optional reset body before sending', async () => { + fetchMock.route('*', 204) + + await expect( + new Customers(client(true)).entitlements.resetUsage({ + customerId, + entitlementId: meterId, + body: { retainAnchor: 'invalid' as never }, + }), + ).rejects.toBeInstanceOf(ValidationError) + + expect(fetchMock.callHistory.calls()).toHaveLength(0) + }) + + it('still rejects an omitted required request body before sending', async () => { + fetchMock.route('*', json({})) + + const result = await funcs.createMeter(client(true), undefined as never) + + expect(result.ok).toBe(false) + expect(result.error).toBeInstanceOf(ValidationError) + expect(fetchMock.callHistory.calls()).toHaveLength(0) + }) + it('omits explicit undefined object properties and record entries in toWire', () => { const wire = toWire( { diff --git a/api/spec/packages/typespec-typescript/test/optional-body.test.ts b/api/spec/packages/typespec-typescript/test/optional-body.test.ts new file mode 100644 index 0000000000..9397a2b564 --- /dev/null +++ b/api/spec/packages/typespec-typescript/test/optional-body.test.ts @@ -0,0 +1,63 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { EmitterTester } from './emit.js' + +const FIXTURE = ` +import "@typespec/http"; +import "@typespec/openapi"; +using TypeSpec.Http; +using TypeSpec.OpenAPI; + +namespace Widgets { + model Options { as_of?: utcDateTime; } + interface Operations { + @post @route("/{id}/optional") @operationId("optional-widget") + optional(@path id: string, @body request?: Options): void; + @post @route("/optional") @operationId("optional-widgets") + optionalOnly(@body request?: Options): void; + @post @route("/required") @operationId("required-widgets") + required(@body request: Options): void; + } +} +@service namespace Api { + @route("/widgets") interface Endpoints extends Widgets.Operations {} +} +` + +describe('optional request bodies', () => { + let outputs: Record + + beforeAll(async () => { + const [result, diagnostics] = + await EmitterTester.compileAndDiagnose(FIXTURE) + expect(diagnostics.filter((d) => d.severity === 'error')).toEqual([]) + outputs = result.outputs + }) + + it('preserves optional bodies alongside required path parameters', () => { + expect(outputs['src/models/operations/widgets.ts']).toContain( + 'id: string\n body?: Options', + ) + expect(outputs['src/funcs/widgets.ts']).toContain( + 'client._options.validate && body !== undefined', + ) + }) + + it('allows omitting a body-only request but keeps required bodies required', () => { + const operations = outputs['src/models/operations/widgets.ts']! + expect(operations).toContain( + 'OptionalWidgetsRequest = AcceptDateStrings | undefined', + ) + expect(operations).toContain( + 'RequiredWidgetsRequest = AcceptDateStrings\n', + ) + const funcs = outputs['src/funcs/widgets.ts']! + expect(funcs).toContain('req?: OptionalWidgetsRequest') + expect(funcs).toContain('req: RequiredWidgetsRequest,') + expect(funcs).toContain( + 'if (client._options.validate) {\n assertValid(schemas.requiredWidgetsBodyWire, body)', + ) + expect(outputs['src/sdk/widgets.ts']).toContain( + 'request?: OptionalWidgetsRequest', + ) + }) +}) From 47658d35c2b859124e85c34d431b1567e5956e42 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:01:13 +0100 Subject: [PATCH 2/2] refactor(sdk): derive body optionality uniformly and cover query-bearing optional bodies Shared-route body overrides no longer force a required body; the declared body's optionality applies to every operation. Generated output is unchanged. Add a fixture operation with an optional body plus query parameters to lock in the remaining request shape. --- .../typespec-typescript/src/sdk-operations.ts | 4 +--- .../test/optional-body.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/api/spec/packages/typespec-typescript/src/sdk-operations.ts b/api/spec/packages/typespec-typescript/src/sdk-operations.ts index 67ccc3aa73..229621a076 100644 --- a/api/spec/packages/typespec-typescript/src/sdk-operations.ts +++ b/api/spec/packages/typespec-typescript/src/sdk-operations.ts @@ -188,9 +188,7 @@ export function sdkOperation( nestPath, hasBody: httpOp.parameters.body?.type !== undefined || bodyOverrides.has(base), - bodyOptional: - !bodyOverrides.has(base) && - (httpOp.parameters.body?.property?.optional ?? false), + bodyOptional: httpOp.parameters.body?.property?.optional ?? false, hasResponse: responseBody !== undefined, requestBodyInterface, responseInterface, diff --git a/api/spec/packages/typespec-typescript/test/optional-body.test.ts b/api/spec/packages/typespec-typescript/test/optional-body.test.ts index 9397a2b564..01619b6978 100644 --- a/api/spec/packages/typespec-typescript/test/optional-body.test.ts +++ b/api/spec/packages/typespec-typescript/test/optional-body.test.ts @@ -14,6 +14,8 @@ namespace Widgets { optional(@path id: string, @body request?: Options): void; @post @route("/optional") @operationId("optional-widgets") optionalOnly(@body request?: Options): void; + @post @route("/optional-query") @operationId("optional-query-widgets") + optionalQuery(@query q?: string, @body request?: Options): void; @post @route("/required") @operationId("required-widgets") required(@body request: Options): void; } @@ -60,4 +62,21 @@ describe('optional request bodies', () => { 'request?: OptionalWidgetsRequest', ) }) + + it('keeps a query-bearing request required while its body stays optional', () => { + expect(outputs['src/models/operations/widgets.ts']).toContain( + 'OptionalQueryWidgetsRequest = AcceptDateStrings<{\n body?: Options\n} & OptionalQueryWidgetsQuery>', + ) + const funcs = outputs['src/funcs/widgets.ts']! + expect(funcs).toContain('req: OptionalQueryWidgetsRequest,') + expect(funcs).toContain( + 'toWire(req.body, schemas.optionalQueryWidgetsBody)', + ) + expect(funcs).toContain( + 'if (client._options.validate && body !== undefined) {\n assertValid(schemas.optionalQueryWidgetsBodyWire, body)', + ) + expect(outputs['src/sdk/widgets.ts']).toContain( + 'request: OptionalQueryWidgetsRequest', + ) + }) })