Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export type ListCustomerEntitlementsResponse = EntitlementPagePaginatedResponse
export type ResetCustomerEntitlementUsageRequest = AcceptDateStrings<{
customerId: string
entitlementId: string
body: ResetCustomerEntitlementUsageRequestInput
body?: ResetCustomerEntitlementUsageRequestInput
}>
export type ResetCustomerEntitlementUsageResponse = void

Expand Down Expand Up @@ -306,7 +306,7 @@ export type CreateCreditAdjustmentResponse = CreditAdjustment
export type VoidCreditGrantRequest = AcceptDateStrings<{
customerId: string
creditGrantId: string
body: VoidCreditGrantRequestInput
body?: VoidCreditGrantRequestInput
}>
export type VoidCreditGrantResponse = CreditGrant

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,6 @@ export type DeleteFeatureResponse = void

export type QueryFeatureCostRequest = AcceptDateStrings<{
featureId: string
body: MeterQueryRequestInput
body?: MeterQueryRequestInput
}>
export type QueryFeatureCostResponse = FeatureCostQueryResult
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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(
{
Expand Down
4 changes: 2 additions & 2 deletions api/spec/packages/typespec-typescript/src/request-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions api/spec/packages/typespec-typescript/src/sdk-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)`,
` }`,
]
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions api/spec/packages/typespec-typescript/src/sdk-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -187,6 +188,7 @@ export function sdkOperation(
nestPath,
hasBody:
httpOp.parameters.body?.type !== undefined || bodyOverrides.has(base),
bodyOptional: httpOp.parameters.body?.property?.optional ?? false,
hasResponse: responseBody !== undefined,
requestBodyInterface,
responseInterface,
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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(
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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("/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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: requestDecl emits a third shape this fixture doesn't cover: an optional body together with query params and no path (AcceptDateStrings<{ body?: X } & Query>). In that shape req/request stays required, and bodyValue is req.body. An extra op such as @post @route("/optional-query") optionalQuery(@query q?: string, @body request?: Options): void; would lock that in.

}
@service namespace Api {
@route("/widgets") interface Endpoints extends Widgets.Operations {}
}
`

describe('optional request bodies', () => {
let outputs: Record<string, string>

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<Options> | undefined',
)
expect(operations).toContain(
'RequiredWidgetsRequest = AcceptDateStrings<Options>\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',
)
})

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',
)
})
})
Loading