diff --git a/packages/typespec-test/test/ai/generated/typespec-ts/src/models/models.ts b/packages/typespec-test/test/ai/generated/typespec-ts/src/models/models.ts index ca3c823a3b..595df042e8 100644 --- a/packages/typespec-test/test/ai/generated/typespec-ts/src/models/models.ts +++ b/packages/typespec-test/test/ai/generated/typespec-ts/src/models/models.ts @@ -477,6 +477,8 @@ export interface _PagedEvaluation { value: Evaluation[]; /** The link to the next page of items */ nextLink?: string; + /** An opaque, globally-unique, client-generated string identifier for the request. */ + clientRequestId?: string; } export function _pagedEvaluationDeserializer(item: any): _PagedEvaluation { @@ -723,6 +725,8 @@ export interface _PagedEvaluationSchedule { value: EvaluationSchedule[]; /** The link to the next page of items */ nextLink?: string; + /** An opaque, globally-unique, client-generated string identifier for the request. */ + clientRequestId?: string; } export function _pagedEvaluationScheduleDeserializer(item: any): _PagedEvaluationSchedule { diff --git a/packages/typespec-test/test/todo_non_branded/generated/typespec-ts/src/models/todoItems/models.ts b/packages/typespec-test/test/todo_non_branded/generated/typespec-ts/src/models/todoItems/models.ts index 525c3302a2..94db321df5 100644 --- a/packages/typespec-test/test/todo_non_branded/generated/typespec-ts/src/models/todoItems/models.ts +++ b/packages/typespec-test/test/todo_non_branded/generated/typespec-ts/src/models/todoItems/models.ts @@ -22,6 +22,10 @@ export interface _TodoPage { pageSize: number; /** The total number of items */ totalSize: number; + /** The limit to the number of items */ + limit?: number; + /** The offset to start paginating at */ + offset?: number; /** A link to the previous page, if it exists */ prevLink?: string; /** A link to the next page, if it exists */ diff --git a/packages/typespec-ts/src/modular/emitModels.ts b/packages/typespec-ts/src/modular/emitModels.ts index ccdaaadd1b..8832117a6d 100644 --- a/packages/typespec-ts/src/modular/emitModels.ts +++ b/packages/typespec-ts/src/modular/emitModels.ts @@ -693,13 +693,31 @@ function buildModelInterface( type: SdkModelType ): InterfaceDeclarationStructure { const flattenPropertySet = new Set(); + // For non-input models (output-only, exception, etc.), filter out metadata + // properties (@header, @query, @path) since they are deserialized separately. + // For input models, keep metadata properties — users need to pass them. + const hasInputUsage = (type.usage & UsageFlags.Input) === UsageFlags.Input; + const isArmResource = isArmResourceModel(type); const interfaceStructure = { kind: StructureKind.Interface, name: normalizeModelName(context, type, NameType.Interface, true), isExported: true, properties: type.properties - .filter((p) => !isMetadata(context.program, p.__raw!)) .filter((p) => { + if (!hasInputUsage && p.__raw && isMetadata(context.program, p.__raw)) { + return false; + } + // Skip the "name" metadata property on ARM resource models. + // ARM resource "name" is a @path property inherited from the base Resource type + // and is handled by the ARM infrastructure, not set by the user directly. + if ( + isArmResource && + p.name === "name" && + p.__raw && + isMetadata(context.program, p.__raw) + ) { + return false; + } // filter out the flatten property to be processed later if (p.flatten && p.type.kind === "model") { flattenPropertySet.add(p); @@ -718,7 +736,7 @@ function buildModelInterface( context, flatten.type, getAllAncestors(flatten.type) - ).filter((p) => !isMetadata(context.program, p.__raw!)); + ); interfaceStructure.properties!.push( ...allProperties.map((p) => { // when the flattened property is optional, all its child properties should be optional too @@ -893,6 +911,22 @@ export function normalizeModelName( return `${internalModelPrefix}${normalizeName(namespacePrefix + type.name, nameType, true)}${unionSuffix}`; } +/** + * Checks if a model extends an ARM resource base type (TrackedResource, ProxyResource, etc.) + * by walking the ancestor chain and checking for Azure.ResourceManager types. + */ +function isArmResourceModel(type: SdkModelType): boolean { + const ancestors = getAllAncestors(type); + return ancestors.some( + (ancestor) => + ancestor.kind === "model" && + ((ancestor.crossLanguageDefinitionId ?? "").startsWith( + "Azure.ResourceManager" + ) || + (ancestor.namespace ?? "").startsWith("Azure.ResourceManager")) + ); +} + function buildModelPolymorphicType(context: SdkContext, type: SdkModelType) { // Only include direct subtypes in this union const directSubtypes = getDirectSubtypes(type); diff --git a/packages/typespec-ts/src/modular/helpers/operationHelpers.ts b/packages/typespec-ts/src/modular/helpers/operationHelpers.ts index f9165b99d1..217b2d6815 100644 --- a/packages/typespec-ts/src/modular/helpers/operationHelpers.ts +++ b/packages/typespec-ts/src/modular/helpers/operationHelpers.ts @@ -85,7 +85,6 @@ import { SdkLroPagingServiceMethod, SdkLroServiceMethod, SdkMethod, - SdkMethodParameter, SdkModelPropertyType, SdkModelType, SdkPagingServiceMethod, @@ -1590,14 +1589,7 @@ function getHeaderAndBodyParameters( if (parametersImplementation.header.length) { paramStr = `${paramStr}\nheaders: {${parametersImplementation.header - .map((i) => - buildHeaderParameter( - dpgContext.program, - i.paramMap, - i.param, - optionalParamName - ) - ) + .map((i) => buildHeaderParameter(dpgContext.program, i.paramMap, i.param)) .join(",\n")}, ...${optionalParamName}.requestOptions?.headers },`; } if ( @@ -1617,10 +1609,8 @@ function getHeaderAndBodyParameters( function buildHeaderParameter( program: Program, paramMap: string, - param: SdkHttpParameter, - optionalParamName: string = "options" + param: SdkHttpParameter ): string { - const paramName = param.name; const effectiveOptional = getEffectiveOptional(param); if (!effectiveOptional && isTypeNullable(param.type) === true) { reportDiagnostic(program, { @@ -1629,12 +1619,17 @@ function buildHeaderParameter( }); return paramMap; } + + // paramMap is in the format '"key": valueExpr', extract the value expression + // to use as the condition accessor instead of recomputing it. + const valueExpr = paramMap.substring(paramMap.indexOf(": ") + 2); + const conditions = []; if (effectiveOptional) { - conditions.push(`${optionalParamName}?.${paramName} !== undefined`); + conditions.push(`${valueExpr} !== undefined`); } if (isTypeNullable(param.type) === true) { - conditions.push(`${optionalParamName}?.${paramName} !== null`); + conditions.push(`${valueExpr} !== null`); } return conditions.length > 0 ? `...(${conditions.join(" && ")} ? {${paramMap}} : {})` @@ -1795,13 +1790,21 @@ export function getParameterMap( ); } + const methodParamExpr = getMethodParamExpr(param); + // if the parameter or property is optional, we don't need to handle the default value if (isOptional(param)) { - return getOptional(context, param, optionalParamName, serializedName); + return getOptional( + context, + param, + optionalParamName, + serializedName, + methodParamExpr + ); } if (isRequired(param)) { - return getRequired(context, param, serializedName); + return getRequired(context, param, serializedName, methodParamExpr); } reportDiagnostic(context.program, { @@ -1895,9 +1898,12 @@ function isRequired(param: SdkHttpParameter) { function getRequired( context: SdkContext, param: SdkHttpParameter, - serializedName: string + serializedName: string, + methodParamExpr?: string ) { - const clientValue = `${param.onClient ? "context." : ""}${param.name}`; + const clientValue = param.onClient + ? `context.${param.name}` + : (methodParamExpr ?? param.name); if (param.type.kind === "model") { const propertiesStr = getRequestModelMapping( context, @@ -1936,9 +1942,22 @@ function getOptional( context: SdkContext, param: SdkHttpParameter, optionalParamName: string, - serializedName: string + serializedName: string, + methodParamExpr?: string ) { - const paramName = `${param.onClient ? "context." : `${optionalParamName}?.`}${param.name}`; + let paramName: string; + if (param.onClient) { + paramName = `context.${param.name}`; + } else if (methodParamExpr) { + const methodParam = param.methodParameterSegments[0]?.[0]; + if (methodParam?.optional) { + paramName = `${optionalParamName}?.${methodParamExpr}`; + } else { + paramName = methodParamExpr; + } + } else { + paramName = `${optionalParamName}?.${param.name}`; + } // Apply client default value if present and type matches const defaultSuffix = @@ -2010,7 +2029,7 @@ function getPathParameters( const methodParam = param.methodParameterSegments[0]?.[0]; if (methodParam) { pathParams.push( - `"${param.serializedName}": ${getPathParamExpr(methodParam, getDefaultValue(param) as string, optionalParamName)}` + `"${param.serializedName}": ${getPathParamExpr(param, getDefaultValue(param) as string, optionalParamName)}` ); } } @@ -2075,19 +2094,53 @@ function escapeUriTemplateParamName(name: string) { }); } +/** Builds a property accessor expression from the param's methodParameterSegments. */ +function getMethodParamExpr(param: SdkHttpParameter): string | undefined { + const segments = param.methodParameterSegments; + if (segments.length === 0) { + return undefined; + } + const path = segments[0]; + if (!path || path.length < 1) { + return undefined; + } + + const parts: string[] = []; + for (let i = 0; i < path.length; i++) { + const segment = path[i]!; + if (i === 0) { + parts.push(segment.name); + } else { + const needsOptionalChain = path[i - 1]!.optional; + parts.push(`${needsOptionalChain ? "?." : "."}${segment.name}`); + } + } + return parts.join(""); +} + function getPathParamExpr( - param: SdkMethodParameter | SdkModelPropertyType, + param: SdkHttpParameter, defaultValue?: string, optionalParamName: string = "options" ) { if (isConstant(param.type)) { return getConstantValue(param.type); } - const paramName = param.onClient - ? `context.${param.name}` - : param.optional - ? `${optionalParamName}["${param.name}"]` - : param.name; + const methodParamExpr = getMethodParamExpr(param); + let paramName: string; + if (param.onClient) { + paramName = `context.${param.name}`; + } else if (methodParamExpr) { + // Only prefix with optionalParamName when the method param itself is optional. + const methodParam = param.methodParameterSegments[0]?.[0]; + if (param.optional && methodParam?.optional) { + paramName = `${optionalParamName}?.${methodParamExpr}`; + } else { + paramName = methodParamExpr; + } + } else { + paramName = param.name; + } return defaultValue ? typeof defaultValue === "string" ? `${paramName} ?? "${defaultValue}"` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/modelsGenerator/modelsGenerator.md b/packages/typespec-ts/test/modularUnit/scenarios/modelsGenerator/modelsGenerator.md index 191b71a3e2..ff438961a4 100644 --- a/packages/typespec-ts/test/modularUnit/scenarios/modelsGenerator/modelsGenerator.md +++ b/packages/typespec-ts/test/modularUnit/scenarios/modelsGenerator/modelsGenerator.md @@ -2469,7 +2469,9 @@ export function bSerializer(item: B): any { /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /** model interface A */ -export interface A extends B {} +export interface A extends B { + name: string; +} ``` ## Model function aSerializer diff --git a/packages/typespec-ts/test/modularUnit/scenarios/operations/bodyMetadataExtraction.md b/packages/typespec-ts/test/modularUnit/scenarios/operations/bodyMetadataExtraction.md new file mode 100644 index 0000000000..0483cf0a51 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/scenarios/operations/bodyMetadataExtraction.md @@ -0,0 +1,370 @@ +# Should extract header from bodyRoot model containing header metadata + +Tests that when a @bodyRoot model contains @header properties, the model interface keeps +the header property, the serializer filters it out, and the operation extracts the header +from the body parameter. + +## TypeSpec + +```tsp +model RequestBody { + @header foo: string; + name: string; + age: int32; +} +@route("/bodyRoot-header") +@post +op bodyRootWithHeader(@bodyRoot body: RequestBody): void; +``` + +## Models + +```ts models +/** + * This file contains only generated model types and their (de)serializers. + * Disable the following rules for internal models with '_' prefix and deserializers which require 'any' for raw JSON input. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/** model interface RequestBody */ +export interface RequestBody { + foo: string; + name: string; + age: number; +} + +export function requestBodySerializer(item: RequestBody): any { + return { name: item["name"], age: item["age"] }; +} +``` + +## Operations + +```ts operations +import { TestingContext as Client } from "./index.js"; +import { RequestBody, requestBodySerializer } from "../models/models.js"; +import { BodyRootWithHeaderOptionalParams } from "./options.js"; +import { + StreamableMethod, + PathUncheckedResponse, + createRestError, + operationOptionsToRequestParameters, +} from "@azure-rest/core-client"; + +export function _bodyRootWithHeaderSend( + context: Client, + body: RequestBody, + options: BodyRootWithHeaderOptionalParams = { requestOptions: {} }, +): StreamableMethod { + return context + .path("/bodyRoot-header") + .post({ + ...operationOptionsToRequestParameters(options), + contentType: "application/json", + headers: { foo: body.foo, ...options.requestOptions?.headers }, + body: requestBodySerializer(body), + }); +} + +export async function _bodyRootWithHeaderDeserialize(result: PathUncheckedResponse): Promise { + const expectedStatuses = ["204"]; + if (!expectedStatuses.includes(result.status)) { + throw createRestError(result); + } + + return; +} + +export async function bodyRootWithHeader( + context: Client, + body: RequestBody, + options: BodyRootWithHeaderOptionalParams = { requestOptions: {} }, +): Promise { + const result = await _bodyRootWithHeaderSend(context, body, options); + return _bodyRootWithHeaderDeserialize(result); +} +``` + +# Should extract path parameter from body model containing path metadata + +Tests that when a body model contains @path properties, the model interface keeps +the path property, the serializer filters it out, and the operation uses the path +parameter from the body model in the URL template. + +## TypeSpec + +```tsp +model ResourceBody { + @path resourceId: string; + name: string; + value: int32; +} +@route("/resources/{resourceId}") +@post +op createResource(body: ResourceBody): void; +``` + +## Models + +```ts models +/** + * This file contains only generated model types and their (de)serializers. + * Disable the following rules for internal models with '_' prefix and deserializers which require 'any' for raw JSON input. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/** model interface ResourceBody */ +export interface ResourceBody { + resourceId: string; + name: string; + value: number; +} + +export function resourceBodySerializer(item: ResourceBody): any { + return { name: item["name"], value: item["value"] }; +} +``` + +## Operations + +```ts operations +import { TestingContext as Client } from "./index.js"; +import { ResourceBody, resourceBodySerializer } from "../models/models.js"; +import { expandUrlTemplate } from "../static-helpers/urlTemplate.js"; +import { CreateResourceOptionalParams } from "./options.js"; +import { + StreamableMethod, + PathUncheckedResponse, + createRestError, + operationOptionsToRequestParameters, +} from "@azure-rest/core-client"; + +export function _createResourceSend( + context: Client, + body: ResourceBody, + options: CreateResourceOptionalParams = { requestOptions: {} }, +): StreamableMethod { + const path = expandUrlTemplate( + "/resources/{resourceId}", + { + resourceId: body.resourceId, + }, + { + allowReserved: options?.requestOptions?.skipUrlEncoding, + }, + ); + return context + .path(path) + .post({ + ...operationOptionsToRequestParameters(options), + contentType: "application/json", + body: { body: resourceBodySerializer(body) }, + }); +} + +export async function _createResourceDeserialize(result: PathUncheckedResponse): Promise { + const expectedStatuses = ["204"]; + if (!expectedStatuses.includes(result.status)) { + throw createRestError(result); + } + + return; +} + +export async function createResource( + context: Client, + body: ResourceBody, + options: CreateResourceOptionalParams = { requestOptions: {} }, +): Promise { + const result = await _createResourceSend(context, body, options); + return _createResourceDeserialize(result); +} +``` + +# Should extract optional header from bodyRoot model containing optional header metadata + +Tests that when a @bodyRoot model contains optional @header properties, the operation +correctly handles the optional header extraction with conditional checks. + +## TypeSpec + +```tsp +model OptionalHeaderBody { + @header foo?: string; + name: string; + age: int32; +} +@route("/bodyRoot-optional-header") +@post +op bodyRootWithOptionalHeader(@bodyRoot body: OptionalHeaderBody): void; +``` + +## Models + +```ts models +/** + * This file contains only generated model types and their (de)serializers. + * Disable the following rules for internal models with '_' prefix and deserializers which require 'any' for raw JSON input. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/** model interface OptionalHeaderBody */ +export interface OptionalHeaderBody { + foo?: string; + name: string; + age: number; +} + +export function optionalHeaderBodySerializer(item: OptionalHeaderBody): any { + return { name: item["name"], age: item["age"] }; +} +``` + +## Operations + +```ts operations +import { TestingContext as Client } from "./index.js"; +import { OptionalHeaderBody, optionalHeaderBodySerializer } from "../models/models.js"; +import { BodyRootWithOptionalHeaderOptionalParams } from "./options.js"; +import { + StreamableMethod, + PathUncheckedResponse, + createRestError, + operationOptionsToRequestParameters, +} from "@azure-rest/core-client"; + +export function _bodyRootWithOptionalHeaderSend( + context: Client, + body: OptionalHeaderBody, + options: BodyRootWithOptionalHeaderOptionalParams = { requestOptions: {} }, +): StreamableMethod { + return context + .path("/bodyRoot-optional-header") + .post({ + ...operationOptionsToRequestParameters(options), + contentType: "application/json", + headers: { + ...(body.foo !== undefined ? { foo: body.foo } : {}), + ...options.requestOptions?.headers, + }, + body: optionalHeaderBodySerializer(body), + }); +} + +export async function _bodyRootWithOptionalHeaderDeserialize( + result: PathUncheckedResponse, +): Promise { + const expectedStatuses = ["204"]; + if (!expectedStatuses.includes(result.status)) { + throw createRestError(result); + } + + return; +} + +export async function bodyRootWithOptionalHeader( + context: Client, + body: OptionalHeaderBody, + options: BodyRootWithOptionalHeaderOptionalParams = { requestOptions: {} }, +): Promise { + const result = await _bodyRootWithOptionalHeaderSend(context, body, options); + return _bodyRootWithOptionalHeaderDeserialize(result); +} +``` + +# Should extract optional path from body model containing optional path metadata + +Tests that when a body model contains optional @path properties, the operation +correctly handles the optional path parameter in the URL template. + +## TypeSpec + +```tsp +model OptionalPathBody { + @path resourceId?: string; + name: string; + value: int32; +} +@route("/resources/{resourceId}") +@post +op createOptionalPathResource(body: OptionalPathBody): void; +``` + +## Models + +```ts models +/** + * This file contains only generated model types and their (de)serializers. + * Disable the following rules for internal models with '_' prefix and deserializers which require 'any' for raw JSON input. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/** model interface OptionalPathBody */ +export interface OptionalPathBody { + resourceId?: string; + name: string; + value: number; +} + +export function optionalPathBodySerializer(item: OptionalPathBody): any { + return { name: item["name"], value: item["value"] }; +} +``` + +## Operations + +```ts operations +import { TestingContext as Client } from "./index.js"; +import { OptionalPathBody, optionalPathBodySerializer } from "../models/models.js"; +import { expandUrlTemplate } from "../static-helpers/urlTemplate.js"; +import { CreateOptionalPathResourceOptionalParams } from "./options.js"; +import { + StreamableMethod, + PathUncheckedResponse, + createRestError, + operationOptionsToRequestParameters, +} from "@azure-rest/core-client"; + +export function _createOptionalPathResourceSend( + context: Client, + body: OptionalPathBody, + options: CreateOptionalPathResourceOptionalParams = { requestOptions: {} }, +): StreamableMethod { + const path = expandUrlTemplate( + "/resources/{resourceId}", + { + resourceId: body.resourceId, + }, + { + allowReserved: options?.requestOptions?.skipUrlEncoding, + }, + ); + return context + .path(path) + .post({ + ...operationOptionsToRequestParameters(options), + contentType: "application/json", + body: { body: optionalPathBodySerializer(body) }, + }); +} + +export async function _createOptionalPathResourceDeserialize( + result: PathUncheckedResponse, +): Promise { + const expectedStatuses = ["204"]; + if (!expectedStatuses.includes(result.status)) { + throw createRestError(result); + } + + return; +} + +export async function createOptionalPathResource( + context: Client, + body: OptionalPathBody, + options: CreateOptionalPathResourceOptionalParams = { requestOptions: {} }, +): Promise { + const result = await _createOptionalPathResourceSend(context, body, options); + return _createOptionalPathResourceDeserialize(result); +} +``` diff --git a/packages/typespec-ts/test/modularUnit/scenarios/operations/override.md b/packages/typespec-ts/test/modularUnit/scenarios/operations/override.md index 00e9212759..af818fab30 100644 --- a/packages/typespec-ts/test/modularUnit/scenarios/operations/override.md +++ b/packages/typespec-ts/test/modularUnit/scenarios/operations/override.md @@ -99,7 +99,7 @@ export async function getSecretOriginal( } ``` -# skip: Should handle parameter grouping when using @@override +# Should handle parameter grouping when using @@override Tests that parameters are correctly grouped into options model when using @@override directive. @@ -149,16 +149,20 @@ withRawContent: true ## Models ```ts models +/** + * This file contains only generated model types and their (de)serializers. + * Disable the following rules for internal models with '_' prefix and deserializers which require 'any' for raw JSON input. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /** model interface GroupParametersOptions */ export interface GroupParametersOptions { param1: string; param2: string; } -export function groupParametersOptionsSerializer( - item: GroupParametersOptions, -): any { - return { param1: item["param1"], param2: item["param2"] }; +export function groupParametersOptionsSerializer(item: GroupParametersOptions): any { + return item; } ``` @@ -202,14 +206,10 @@ export function _groupOriginalSend( allowReserved: optionalParams?.requestOptions?.skipUrlEncoding, }, ); - return context - .path(path) - .get({ ...operationOptionsToRequestParameters(optionalParams) }); + return context.path(path).get({ ...operationOptionsToRequestParameters(optionalParams) }); } -export async function _groupOriginalDeserialize( - result: PathUncheckedResponse, -): Promise { +export async function _groupOriginalDeserialize(result: PathUncheckedResponse): Promise { const expectedStatuses = ["204"]; if (!expectedStatuses.includes(result.status)) { throw createRestError(result);