diff --git a/.chronus/changes/body-consitency-2024-2-27-18-35-44-1.md b/.chronus/changes/body-consitency-2024-2-27-18-35-44-1.md new file mode 100644 index 00000000000..c4598f7a4d1 --- /dev/null +++ b/.chronus/changes/body-consitency-2024-2-27-18-35-44-1.md @@ -0,0 +1,23 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: breaking +packages: + - "@typespec/http" +--- + +Empty model after removing metadata and visibility property result in void always + This means the following case have changed from returning `{}` to no body + + ```tsp + op b1(): {}; + op b2(): {@visibility("none") prop: string}; + op b3(): {@added(Versions.v2) prop: string}; + ``` + + Workaround: Use explicit `@body` + + ```tsp + op b1(): {@body _: {}}; + op b2(): {@body _: {@visibility("none") prop: string}}; + op b3(): {@body _: {@added(Versions.v2) prop: string}}; + ``` diff --git a/.chronus/changes/body-consitency-2024-2-27-18-35-44-2.md b/.chronus/changes/body-consitency-2024-2-27-18-35-44-2.md new file mode 100644 index 00000000000..aa980aab9e0 --- /dev/null +++ b/.chronus/changes/body-consitency-2024-2-27-18-35-44-2.md @@ -0,0 +1,18 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: breaking +packages: + - "@typespec/http" +--- + +Implicit status code always 200 except if response is explicitly `void` + + ```tsp + op c1(): {@header foo: string}; // status code 200 (used to be 204) + ``` + + Solution: Add explicit `@statusCode` + ```tsp + op c1(): {@header foo: string, @statusCode _: 204}; + op c1(): {@header foo: string, ...NoContent}; // or spread common model + ``` diff --git a/.chronus/changes/body-consitency-2024-2-27-18-35-44.md b/.chronus/changes/body-consitency-2024-2-27-18-35-44.md new file mode 100644 index 00000000000..f2f65e02e00 --- /dev/null +++ b/.chronus/changes/body-consitency-2024-2-27-18-35-44.md @@ -0,0 +1,22 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: breaking +packages: + - "@typespec/http" +--- + +`@body` means this is the body + + This change makes it that using `@body` will mean exactly this is the body and everything underneath will be included, including metadata properties. It will log a warning explaining that. + + ```tsp + op a1(): {@body _: {@header foo: string, other: string} }; + ^ warning header in a body, it will not be included as a header. + ``` + + Solution use `@bodyRoot` as the goal is only to change where to resolve the body from. + + ```tsp + op a1(): {@bodyRoot _: {@header foo: string, other: string} }; + ``` + diff --git a/.chronus/changes/body-consitency-2024-3-2-15-21-10.md b/.chronus/changes/body-consitency-2024-3-2-15-21-10.md new file mode 100644 index 00000000000..1a4c8cb7fc5 --- /dev/null +++ b/.chronus/changes/body-consitency-2024-3-2-15-21-10.md @@ -0,0 +1,9 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/openapi3" + - "@typespec/rest" +--- + +Add supoort for new `@bodyRoot` and `@body` distinction diff --git a/.chronus/changes/body-consitency-2024-3-2-15-21-9.md b/.chronus/changes/body-consitency-2024-3-2-15-21-9.md new file mode 100644 index 00000000000..9d8ffa9114a --- /dev/null +++ b/.chronus/changes/body-consitency-2024-3-2-15-21-9.md @@ -0,0 +1,18 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: breaking +packages: + - "@typespec/http" +--- + +Properties are not automatically omitted if everything was removed from metadata or visibility + + ```tsp + op d1(): {headers: {@header foo: string}}; // body will be {headers: {}} + ``` + + Solution: use `@bodyIgnore` + + ```tsp + op d1(): {@bodyIgnore headers: {@header foo: string}}; // body will be {headers: {}} + ``` diff --git a/docs/libraries/http/reference/decorators.md b/docs/libraries/http/reference/decorators.md index f9025ae3ba5..2996c91707e 100644 --- a/docs/libraries/http/reference/decorators.md +++ b/docs/libraries/http/reference/decorators.md @@ -10,7 +10,10 @@ toc_max_heading_level: 3 ### `@body` {#@TypeSpec.Http.body} -Explicitly specify that this property is to be set as the body +Explicitly specify that this property type will be exactly the HTTP body. + +This means that any properties under `@body` cannot be marked as headers, query parameters, or path parameters. +If wanting to change the resolution of the body but still mix parameters, use `@bodyRoot`. ```typespec @TypeSpec.Http.body @@ -33,6 +36,69 @@ op download(): { }; ``` +### `@bodyIgnore` {#@TypeSpec.Http.bodyIgnore} + +Specify that this property shouldn't be included in the HTTP body. +This can be useful when bundling metadata together that would result in an empty property to be included in the body. + +```typespec +@TypeSpec.Http.bodyIgnore +``` + +#### Target + +`ModelProperty` + +#### Parameters + +None + +#### Examples + +```typespec +op upload( + name: string, + @bodyIgnore headers: { + @header id: string; + }, +): void; +``` + +### `@bodyRoot` {#@TypeSpec.Http.bodyRoot} + +Specify that the body resolution should be resolved from that property. +By default the body is resolved by including all properties in the operation request/response that are not metadata. +This allows to nest the body in a property while still allowing to use headers, query parameters, and path parameters in the same model. + +```typespec +@TypeSpec.Http.bodyRoot +``` + +#### Target + +`ModelProperty` + +#### Parameters + +None + +#### Examples + +```typespec +op upload( + @bodyRoot user: { + name: string; + @header id: string; + }, +): void; +op download(): { + @bodyRoot user: { + name: string; + @header id: string; + }; +}; +``` + ### `@delete` {#@TypeSpec.Http.delete} Specify the HTTP verb for the target operation to be `DELETE`. diff --git a/docs/libraries/http/reference/index.mdx b/docs/libraries/http/reference/index.mdx index 035944359bf..ec9a88f52e8 100644 --- a/docs/libraries/http/reference/index.mdx +++ b/docs/libraries/http/reference/index.mdx @@ -36,6 +36,8 @@ npm install --save-peer @typespec/http ### Decorators - [`@body`](./decorators.md#@TypeSpec.Http.body) +- [`@bodyIgnore`](./decorators.md#@TypeSpec.Http.bodyIgnore) +- [`@bodyRoot`](./decorators.md#@TypeSpec.Http.bodyRoot) - [`@delete`](./decorators.md#@TypeSpec.Http.delete) - [`@get`](./decorators.md#@TypeSpec.Http.get) - [`@head`](./decorators.md#@TypeSpec.Http.head) diff --git a/packages/http/README.md b/packages/http/README.md index 4c7ffb90f3b..a1c847b0ad0 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -37,6 +37,8 @@ Available ruleSets: ### TypeSpec.Http - [`@body`](#@body) +- [`@bodyIgnore`](#@bodyignore) +- [`@bodyRoot`](#@bodyroot) - [`@delete`](#@delete) - [`@get`](#@get) - [`@head`](#@head) @@ -55,7 +57,10 @@ Available ruleSets: #### `@body` -Explicitly specify that this property is to be set as the body +Explicitly specify that this property type will be exactly the HTTP body. + +This means that any properties under `@body` cannot be marked as headers, query parameters, or path parameters. +If wanting to change the resolution of the body but still mix parameters, use `@bodyRoot`. ```typespec @TypeSpec.Http.body @@ -78,6 +83,69 @@ op download(): { }; ``` +#### `@bodyIgnore` + +Specify that this property shouldn't be included in the HTTP body. +This can be useful when bundling metadata together that would result in an empty property to be included in the body. + +```typespec +@TypeSpec.Http.bodyIgnore +``` + +##### Target + +`ModelProperty` + +##### Parameters + +None + +##### Examples + +```typespec +op upload( + name: string, + @bodyIgnore headers: { + @header id: string; + }, +): void; +``` + +#### `@bodyRoot` + +Specify that the body resolution should be resolved from that property. +By default the body is resolved by including all properties in the operation request/response that are not metadata. +This allows to nest the body in a property while still allowing to use headers, query parameters, and path parameters in the same model. + +```typespec +@TypeSpec.Http.bodyRoot +``` + +##### Target + +`ModelProperty` + +##### Parameters + +None + +##### Examples + +```typespec +op upload( + @bodyRoot user: { + name: string; + @header id: string; + }, +): void; +op download(): { + @bodyRoot user: { + name: string; + @header id: string; + }; +}; +``` + #### `@delete` Specify the HTTP verb for the target operation to be `DELETE`. diff --git a/packages/http/generated-defs/TypeSpec.Http.ts b/packages/http/generated-defs/TypeSpec.Http.ts index 8fffdee80b9..449ecf29049 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts @@ -19,7 +19,10 @@ import type { export type StatusCodeDecorator = (context: DecoratorContext, target: ModelProperty) => void; /** - * Explicitly specify that this property is to be set as the body + * Explicitly specify that this property type will be exactly the HTTP body. + * + * This means that any properties under `@body` cannot be marked as headers, query parameters, or path parameters. + * If wanting to change the resolution of the body but still mix parameters, use `@bodyRoot`. * * @example * ```typespec @@ -84,6 +87,30 @@ export type PathDecorator = ( paramName?: string ) => void; +/** + * Specify that the body resolution should be resolved from that property. + * By default the body is resolved by including all properties in the operation request/response that are not metadata. + * This allows to nest the body in a property while still allowing to use headers, query parameters, and path parameters in the same model. + * + * @example + * ```typespec + * op upload(@bodyRoot user: {name: string, @header id: string}): void; + * op download(): {@bodyRoot user: {name: string, @header id: string}}; + * ``` + */ +export type BodyRootDecorator = (context: DecoratorContext, target: ModelProperty) => void; + +/** + * Specify that this property shouldn't be included in the HTTP body. + * This can be useful when bundling metadata together that would result in an empty property to be included in the body. + * + * @example + * ```typespec + * op upload(name: string, @bodyIgnore headers: {@header id: string}): void; + * ``` + */ +export type BodyIgnoreDecorator = (context: DecoratorContext, target: ModelProperty) => void; + /** * Specify the HTTP verb for the target operation to be `GET`. * diff --git a/packages/http/generated-defs/TypeSpec.Http.ts-test.ts b/packages/http/generated-defs/TypeSpec.Http.ts-test.ts index 02e19d6022a..6dfec87e9f0 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts-test.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts-test.ts @@ -1,6 +1,8 @@ /** An error here would mean that the decorator is not exported or doesn't have the right name. */ import { $body, + $bodyIgnore, + $bodyRoot, $delete, $get, $head, @@ -19,6 +21,8 @@ import { } from "@typespec/http"; import { BodyDecorator, + BodyIgnoreDecorator, + BodyRootDecorator, DeleteDecorator, GetDecorator, HeadDecorator, @@ -42,6 +46,8 @@ type Decorators = { $header: HeaderDecorator; $query: QueryDecorator; $path: PathDecorator; + $bodyRoot: BodyRootDecorator; + $bodyIgnore: BodyIgnoreDecorator; $get: GetDecorator; $put: PutDecorator; $post: PostDecorator; @@ -62,6 +68,8 @@ const _: Decorators = { $header, $query, $path, + $bodyRoot, + $bodyIgnore, $get, $put, $post, diff --git a/packages/http/lib/http-decorators.tsp b/packages/http/lib/http-decorators.tsp index 79755250e7b..f8631f23cbc 100644 --- a/packages/http/lib/http-decorators.tsp +++ b/packages/http/lib/http-decorators.tsp @@ -83,7 +83,10 @@ extern dec query(target: ModelProperty, queryNameOrOptions?: string | QueryOptio extern dec path(target: ModelProperty, paramName?: valueof string); /** - * Explicitly specify that this property is to be set as the body + * Explicitly specify that this property type will be exactly the HTTP body. + * + * This means that any properties under `@body` cannot be marked as headers, query parameters, or path parameters. + * If wanting to change the resolution of the body but still mix parameters, use `@bodyRoot`. * * @example * @@ -94,6 +97,31 @@ extern dec path(target: ModelProperty, paramName?: valueof string); */ extern dec body(target: ModelProperty); +/** + * Specify that the body resolution should be resolved from that property. + * By default the body is resolved by including all properties in the operation request/response that are not metadata. + * This allows to nest the body in a property while still allowing to use headers, query parameters, and path parameters in the same model. + * + * @example + * + * ```typespec + * op upload(@bodyRoot user: {name: string, @header id: string}): void; + * op download(): {@bodyRoot user: {name: string, @header id: string}}; + * ``` + */ +extern dec bodyRoot(target: ModelProperty); +/** + * Specify that this property shouldn't be included in the HTTP body. + * This can be useful when bundling metadata together that would result in an empty property to be included in the body. + * + * @example + * + * ```typespec + * op upload(name: string, @bodyIgnore headers: {@header id: string}): void; + * ``` + */ +extern dec bodyIgnore(target: ModelProperty); + /** * Specify the status code for this response. Property type must be a status code integer or a union of status code integer. * diff --git a/packages/http/src/body.ts b/packages/http/src/body.ts new file mode 100644 index 00000000000..462396bc4e3 --- /dev/null +++ b/packages/http/src/body.ts @@ -0,0 +1,174 @@ +import { + Diagnostic, + DuplicateTracker, + ModelProperty, + Program, + Type, + createDiagnosticCollector, + filterModelProperties, + getDiscriminator, + isArrayModelType, + navigateType, +} from "@typespec/compiler"; +import { + isBody, + isBodyRoot, + isHeader, + isPathParam, + isQueryParam, + isStatusCode, +} from "./decorators.js"; +import { createDiagnostic } from "./lib.js"; +import { Visibility, isVisible } from "./metadata.js"; + +export interface ResolvedBody { + readonly type: Type; + /** `true` if the body was specified with `@body` */ + readonly isExplicit: boolean; + /** If the body original model contained property annotated with metadata properties. */ + readonly containsMetadataAnnotations: boolean; + /** If body is defined with `@body` or `@bodyRoot` this is the property */ + readonly property?: ModelProperty; +} + +export function resolveBody( + program: Program, + requestOrResponseType: Type, + metadata: Set, + rootPropertyMap: Map, + visibility: Visibility, + usedIn: "request" | "response" +): [ResolvedBody | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + // non-model or intrinsic/array model -> response body is response type + if (requestOrResponseType.kind !== "Model" || isArrayModelType(program, requestOrResponseType)) { + return diagnostics.wrap({ + type: requestOrResponseType, + isExplicit: false, + containsMetadataAnnotations: false, + }); + } + + const duplicateTracker = new DuplicateTracker(); + + // look for explicit body + let resolvedBody: ResolvedBody | undefined; + for (const property of metadata) { + const isBodyVal = isBody(program, property); + const isBodyRootVal = isBodyRoot(program, property); + if (isBodyVal || isBodyRootVal) { + duplicateTracker.track("body", property); + let containsMetadataAnnotations = false; + if (isBodyVal) { + const valid = diagnostics.pipe(validateBodyProperty(program, property, usedIn)); + containsMetadataAnnotations = !valid; + } + if (resolvedBody === undefined) { + resolvedBody = { + type: property.type, + isExplicit: isBodyVal, + containsMetadataAnnotations, + property, + }; + } + } + } + for (const [_, items] of duplicateTracker.entries()) { + for (const prop of items) { + diagnostics.add( + createDiagnostic({ + code: "duplicate-body", + target: prop, + }) + ); + } + } + if (resolvedBody === undefined) { + // Special case if the model as a parent model then we'll return an empty object as this is assumed to be a nominal type. + // Special Case if the model has an indexer then it means it can return props so cannot be void. + if (requestOrResponseType.baseModel || requestOrResponseType.indexer) { + return diagnostics.wrap({ + type: requestOrResponseType, + isExplicit: false, + containsMetadataAnnotations: false, + }); + } + // Special case for legacy purposes if the return type is an empty model with only @discriminator("xyz") + // Then we still want to return that object as it technically always has a body with that implicit property. + if ( + requestOrResponseType.derivedModels.length > 0 && + getDiscriminator(program, requestOrResponseType) + ) { + return diagnostics.wrap({ + type: requestOrResponseType, + isExplicit: false, + containsMetadataAnnotations: false, + }); + } + } + + const bodyRoot = resolvedBody?.property ? rootPropertyMap.get(resolvedBody.property) : undefined; + + const unannotatedProperties = filterModelProperties( + program, + requestOrResponseType, + (p) => !metadata.has(p) && p !== bodyRoot && isVisible(program, p, visibility) + ); + + if (unannotatedProperties.properties.size > 0) { + if (resolvedBody === undefined) { + return diagnostics.wrap({ + type: unannotatedProperties, + isExplicit: false, + containsMetadataAnnotations: false, + }); + } else { + diagnostics.add( + createDiagnostic({ + code: "duplicate-body", + messageId: "bodyAndUnannotated", + target: requestOrResponseType, + }) + ); + } + } + + return diagnostics.wrap(resolvedBody); +} + +/** Validate a property marked with `@body` */ +export function validateBodyProperty( + program: Program, + property: ModelProperty, + usedIn: "request" | "response" +): [boolean, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + navigateType( + property.type, + { + modelProperty: (prop) => { + const kind = isHeader(program, prop) + ? "header" + : usedIn === "request" && isQueryParam(program, prop) + ? "query" + : usedIn === "request" && isPathParam(program, prop) + ? "path" + : usedIn === "response" && isStatusCode(program, prop) + ? "statusCode" + : undefined; + + if (kind) { + diagnostics.add( + createDiagnostic({ + code: "metadata-ignored", + format: { kind }, + target: prop, + }) + ); + } + }, + }, + {} + ); + return diagnostics.wrap(diagnostics.diagnostics.length === 0); +} diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index b6ebc2534b2..7325cd55315 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -25,6 +25,8 @@ import { import { PlainDataDecorator } from "../generated-defs/TypeSpec.Http.Private.js"; import { BodyDecorator, + BodyIgnoreDecorator, + BodyRootDecorator, DeleteDecorator, GetDecorator, HeadDecorator, @@ -196,10 +198,29 @@ export const $body: BodyDecorator = (context: DecoratorContext, entity: ModelPro context.program.stateSet(HttpStateKeys.body).add(entity); }; +export const $bodyRoot: BodyRootDecorator = (context: DecoratorContext, entity: ModelProperty) => { + context.program.stateSet(HttpStateKeys.bodyRoot).add(entity); +}; + +export const $bodyIgnore: BodyIgnoreDecorator = ( + context: DecoratorContext, + entity: ModelProperty +) => { + context.program.stateSet(HttpStateKeys.bodyIgnore).add(entity); +}; + export function isBody(program: Program, entity: Type): boolean { return program.stateSet(HttpStateKeys.body).has(entity); } +export function isBodyRoot(program: Program, entity: ModelProperty): boolean { + return program.stateSet(HttpStateKeys.bodyRoot).has(entity); +} + +export function isBodyIgnore(program: Program, entity: ModelProperty): boolean { + return program.stateSet(HttpStateKeys.bodyIgnore).has(entity); +} + export const $statusCode: StatusCodeDecorator = ( context: DecoratorContext, entity: ModelProperty diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index 3e1be350aba..fa1ff2f18ae 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -87,6 +87,12 @@ export const $lib = createTypeSpecLibrary({ default: "`Content-Type` header ignored because there is no body.", }, }, + "metadata-ignored": { + severity: "warning", + messages: { + default: paramMessage`${"kind"} property will be ignored as it is inside of a @body property. Use @bodyRoot instead if wanting to mix.`, + }, + }, "no-service-found": { severity: "warning", messages: { @@ -136,6 +142,8 @@ export const $lib = createTypeSpecLibrary({ query: { description: "State for the @query decorator" }, path: { description: "State for the @path decorator" }, body: { description: "State for the @body decorator" }, + bodyRoot: { description: "State for the @bodyRoot decorator" }, + bodyIgnore: { description: "State for the @bodyIgnore decorator" }, statusCode: { description: "State for the @statusCode decorator" }, verbs: { description: "State for the verb decorators (@get, @post, @put, etc.)" }, servers: { description: "State for the @server decorator" }, diff --git a/packages/http/src/metadata.ts b/packages/http/src/metadata.ts index a022e124318..2108d89a23f 100644 --- a/packages/http/src/metadata.ts +++ b/packages/http/src/metadata.ts @@ -17,6 +17,8 @@ import { import { includeInapplicableMetadataInPayload, isBody, + isBodyIgnore, + isBodyRoot, isHeader, isPathParam, isQueryParam, @@ -264,6 +266,9 @@ export function gatherMetadata( if (isApplicableMetadataOrBody(program, property, visibility, isMetadataCallback)) { metadata.set(property.name, property); rootMapOut?.set(property, root); + if (isBody(program, property)) { + continue; // We ignore any properties under `@body` + } } if ( @@ -343,7 +348,7 @@ function isApplicableMetadataCore( return false; // no metadata is applicable to collection items } - if (treatBodyAsMetadata && isBody(program, property)) { + if (treatBodyAsMetadata && (isBody(program, property) || isBodyRoot(program, property))) { return true; } @@ -378,6 +383,7 @@ export interface MetadataInfo { * * When the type of a property is emptied by visibility, the property * itself is also removed. + * @deprecated This produces inconsistent behaviors and should be avoided. */ isEmptied(type: Type | undefined, visibility: Visibility): boolean; @@ -393,7 +399,11 @@ export interface MetadataInfo { * payload and not applicable metadata {@link isApplicableMetadata} or * filtered out by the given visibility. */ - isPayloadProperty(property: ModelProperty, visibility: Visibility): boolean; + isPayloadProperty( + property: ModelProperty, + visibility: Visibility, + inExplicitBody?: boolean + ): boolean; /** * Determines if the given property is optional in the request or @@ -529,8 +539,8 @@ export function createMetadataInfo(program: Program, options?: MetadataInfoOptio return true; } return ( - isPayloadProperty(property, visibility, /* keep shared */ true) !== - isPayloadProperty(property, canonicalVisibility, /*keep shared*/ true) + isPayloadProperty(property, visibility, undefined, /* keep shared */ true) !== + isPayloadProperty(property, canonicalVisibility, undefined, /*keep shared*/ true) ); } @@ -539,7 +549,7 @@ export function createMetadataInfo(program: Program, options?: MetadataInfoOptio return false; } for (const property of model.properties.values()) { - if (isPayloadProperty(property, visibility, /* keep shared */ true)) { + if (isPayloadProperty(property, visibility, undefined, /* keep shared */ true)) { return false; } } @@ -559,12 +569,14 @@ export function createMetadataInfo(program: Program, options?: MetadataInfoOptio function isPayloadProperty( property: ModelProperty, visibility: Visibility, + inExplicitBody?: boolean, keepShareableProperties?: boolean ): boolean { if ( - isEmptied(property.type, visibility) || - isApplicableMetadata(program, property, visibility) || - (isMetadata(program, property) && !includeInapplicableMetadataInPayload(program, property)) + !inExplicitBody && + (isBodyIgnore(program, property) || + isApplicableMetadata(program, property, visibility) || + (isMetadata(program, property) && !includeInapplicableMetadataInPayload(program, property))) ) { return false; } @@ -580,7 +592,7 @@ export function createMetadataInfo(program: Program, options?: MetadataInfoOptio // For OpenAPI emit, for example, this means that we won't put a // readOnly: true property into a specialized schema for a non-read // visibility. - keepShareableProperties ||= visibility === canonicalVisibility; + keepShareableProperties ??= visibility === canonicalVisibility; return !!(keepShareableProperties && options?.canShareProperty?.(property)); } @@ -594,7 +606,7 @@ export function createMetadataInfo(program: Program, options?: MetadataInfoOptio function getEffectivePayloadType(type: Type, visibility: Visibility): Type { if (type.kind === "Model" && !type.name) { const effective = getEffectiveModelType(program, type, (p) => - isPayloadProperty(p, visibility) + isPayloadProperty(p, visibility, undefined, /* keep shared */ false) ); if (effective.name) { return effective; diff --git a/packages/http/src/parameters.ts b/packages/http/src/parameters.ts index 675a1319ea5..1b26aa9ace9 100644 --- a/packages/http/src/parameters.ts +++ b/packages/http/src/parameters.ts @@ -1,12 +1,11 @@ import { createDiagnosticCollector, Diagnostic, - filterModelProperties, ModelProperty, Operation, Program, - Type, } from "@typespec/compiler"; +import { resolveBody, ResolvedBody } from "./body.js"; import { getContentTypes, isContentTypeHeader } from "./content-types.js"; import { getHeaderFieldOptions, @@ -14,6 +13,7 @@ import { getPathParamOptions, getQueryParamOptions, isBody, + isBodyRoot, } from "./decorators.js"; import { createDiagnostic } from "./lib.js"; import { gatherMetadata, isMetadata, resolveRequestVisibility } from "./metadata.js"; @@ -76,8 +76,9 @@ function getOperationParametersForVerb( } const parameters: HttpOperationParameter[] = []; - let bodyType: Type | undefined; - let bodyParameter: ModelProperty | undefined; + const resolvedBody = diagnostics.pipe( + resolveBody(program, operation.parameters, metadata, rootPropertyMap, visibility, "request") + ); let contentTypes: string[] | undefined; for (const param of metadata) { @@ -86,12 +87,13 @@ function getOperationParametersForVerb( getPathParamOptions(program, param) ?? (isImplicitPathParam(param) && { type: "path", name: param.name }); const headerOptions = getHeaderFieldOptions(program, param); - const bodyParam = isBody(program, param); + const isBodyVal = isBody(program, param); + const isBodyRootVal = isBodyRoot(program, param); const defined = [ ["query", queryOptions], ["path", pathOptions], ["header", headerOptions], - ["body", bodyParam], + ["body", isBodyVal || isBodyRootVal], ].filter((x) => !!x[1]); if (defined.length >= 2) { diagnostics.add( @@ -130,39 +132,10 @@ function getOperationParametersForVerb( ...headerOptions, param, }); - } else if (bodyParam) { - if (bodyType === undefined) { - bodyParameter = param; - bodyType = param.type; - } else { - diagnostics.add(createDiagnostic({ code: "duplicate-body", target: param })); - } } } - const bodyRoot = bodyParameter ? rootPropertyMap.get(bodyParameter) : undefined; - const unannotatedProperties = filterModelProperties( - program, - operation.parameters, - (p) => !metadata.has(p) && p !== bodyRoot - ); - - if (unannotatedProperties.properties.size > 0) { - if (bodyType === undefined) { - bodyType = unannotatedProperties; - } else { - diagnostics.add( - createDiagnostic({ - code: "duplicate-body", - messageId: "bodyAndUnannotated", - target: operation, - }) - ); - } - } - const body = diagnostics.pipe( - computeHttpOperationBody(operation, bodyType, bodyParameter, contentTypes) - ); + const body = diagnostics.pipe(computeHttpOperationBody(operation, resolvedBody, contentTypes)); return diagnostics.wrap({ parameters, @@ -179,13 +152,12 @@ function getOperationParametersForVerb( function computeHttpOperationBody( operation: Operation, - bodyType: Type | undefined, - bodyProperty: ModelProperty | undefined, + resolvedBody: ResolvedBody | undefined, contentTypes: string[] | undefined ): [HttpOperationRequestBody | undefined, readonly Diagnostic[]] { contentTypes ??= []; const diagnostics: Diagnostic[] = []; - if (bodyType === undefined) { + if (resolvedBody === undefined) { if (contentTypes.length > 0) { diagnostics.push( createDiagnostic({ @@ -197,20 +169,24 @@ function computeHttpOperationBody( return [undefined, diagnostics]; } - if (contentTypes.includes("multipart/form-data") && bodyType.kind !== "Model") { + if (contentTypes.includes("multipart/form-data") && resolvedBody.type.kind !== "Model") { diagnostics.push( createDiagnostic({ code: "multipart-model", - target: bodyProperty ?? operation.parameters, + target: resolvedBody.property ?? operation.parameters, }) ); return [undefined, diagnostics]; } const body: HttpOperationRequestBody = { - type: bodyType, - parameter: bodyProperty, + type: resolvedBody.type, + isExplicit: resolvedBody.isExplicit, + containsMetadataAnnotations: resolvedBody.containsMetadataAnnotations, contentTypes, }; + if (resolvedBody.property) { + body.parameter = resolvedBody.property; + } return [body, diagnostics]; } diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index 7015a50f8ca..37d16402c89 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -5,7 +5,6 @@ import { getDoc, getErrorsDoc, getReturnsDoc, - isArrayModelType, isErrorModel, isNullType, isVoidType, @@ -14,19 +13,18 @@ import { Operation, Program, Type, - walkPropertiesInherited, } from "@typespec/compiler"; +import { resolveBody, ResolvedBody } from "./body.js"; import { getContentTypes, isContentTypeHeader } from "./content-types.js"; import { getHeaderFieldName, getStatusCodeDescription, getStatusCodesWithDiagnostics, - isBody, isHeader, isStatusCode, } from "./decorators.js"; import { createDiagnostic, HttpStateKeys, reportDiagnostic } from "./lib.js"; -import { gatherMetadata, isApplicableMetadata, Visibility } from "./metadata.js"; +import { gatherMetadata, Visibility } from "./metadata.js"; import { HttpOperationResponse, HttpStatusCodes, HttpStatusCodesEntry } from "./types.js"; /** @@ -88,7 +86,15 @@ function processResponseType( responses: ResponseIndex, responseType: Type ) { - const metadata = gatherMetadata(program, diagnostics, responseType, Visibility.Read); + const rootPropertyMap = new Map(); + const metadata = gatherMetadata( + program, + diagnostics, + responseType, + Visibility.Read, + undefined, + rootPropertyMap + ); // Get explicity defined status codes const statusCodes: HttpStatusCodes = diagnostics.pipe( @@ -102,22 +108,27 @@ function processResponseType( const headers = getResponseHeaders(program, metadata); // Get body - let bodyType = getResponseBody(program, diagnostics, responseType, metadata); + let resolvedBody = diagnostics.pipe( + resolveBody(program, responseType, metadata, rootPropertyMap, Visibility.Read, "response") + ); // If there is no explicit status code, check if it should be 204 if (statusCodes.length === 0) { - if (bodyType === undefined || isVoidType(bodyType)) { - bodyType = undefined; - statusCodes.push(204); - } else if (isErrorModel(program, responseType)) { + if (isErrorModel(program, responseType)) { statusCodes.push("*"); + } else if (isVoidType(responseType)) { + resolvedBody = undefined; + statusCodes.push(204); // Only special case for 204 is op test(): void; + } else if (resolvedBody === undefined || isVoidType(resolvedBody.type)) { + resolvedBody = undefined; + statusCodes.push(200); } else { statusCodes.push(200); } } // If there is a body but no explicit content types, use application/json - if (bodyType && contentTypes.length === 0) { + if (resolvedBody && contentTypes.length === 0) { contentTypes.push("application/json"); } @@ -129,12 +140,24 @@ function processResponseType( statusCode: typeof statusCode === "object" ? "*" : (String(statusCode) as any), statusCodes: statusCode, type: responseType, - description: getResponseDescription(program, operation, responseType, statusCode, bodyType), + description: getResponseDescription( + program, + operation, + responseType, + statusCode, + resolvedBody + ), responses: [], }; - if (bodyType !== undefined) { - response.responses.push({ body: { contentTypes: contentTypes, type: bodyType }, headers }); + if (resolvedBody !== undefined) { + response.responses.push({ + body: { + contentTypes: contentTypes, + ...resolvedBody, + }, + headers, + }); } else if (contentTypes.length > 0) { diagnostics.add( createDiagnostic({ @@ -227,54 +250,12 @@ function getResponseHeaders( return responseHeaders; } -function getResponseBody( - program: Program, - diagnostics: DiagnosticCollector, - responseType: Type, - metadata: Set -): Type | undefined { - // non-model or intrinsic/array model -> response body is response type - if (responseType.kind !== "Model" || isArrayModelType(program, responseType)) { - return responseType; - } - - // look for explicit body - let bodyProperty: ModelProperty | undefined; - for (const property of metadata) { - if (isBody(program, property)) { - if (bodyProperty) { - diagnostics.add(createDiagnostic({ code: "duplicate-body", target: property })); - } else { - bodyProperty = property; - } - } - } - if (bodyProperty) { - return bodyProperty.type; - } - - // Without an explicit body, response type is response model itself if - // there it has at least one non-metadata property, if it is an empty object or if it has derived - // models - if (responseType.derivedModels.length > 0 || responseType.properties.size === 0) { - return responseType; - } - for (const property of walkPropertiesInherited(responseType)) { - if (!isApplicableMetadata(program, property, Visibility.Read)) { - return responseType; - } - } - - // Otherwise, there is no body - return undefined; -} - function getResponseDescription( program: Program, operation: Operation, responseType: Type, statusCode: HttpStatusCodes[number], - bodyType: Type | undefined + body: ResolvedBody | undefined ): string | undefined { // NOTE: If the response type is an envelope and not the same as the body // type, then use its @doc as the response description. However, if the @@ -283,7 +264,7 @@ function getResponseDescription( // as the response description. This allows more freedom to change how // TypeSpec is expressed in semantically equivalent ways without causing // the output to change unnecessarily. - if (responseType !== bodyType) { + if (body === undefined || body.property) { const desc = getDoc(program, responseType); if (desc) { return desc; diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index 6877bfbe8e6..e7ab7deb5df 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -323,11 +323,18 @@ export type HttpOperationParameter = ( */ export interface HttpOperationRequestBody extends HttpOperationBody { /** - * If the body was explicitly set as a property. Correspond to the property with `@body` + * If the body was explicitly set as a property. Correspond to the property with `@body` or `@bodyRoot` */ parameter?: ModelProperty; } +export interface HttpOperationResponseBody extends HttpOperationBody { + /** + * If the body was explicitly set as a property. Correspond to the property with `@body` or `@bodyRoot` + */ + readonly property?: ModelProperty; +} + export interface HttpOperationParameters { parameters: HttpOperationParameter[]; @@ -439,7 +446,7 @@ export interface HttpOperationResponse { export interface HttpOperationResponseContent { headers?: Record; - body?: HttpOperationBody; + body?: HttpOperationResponseBody; } export interface HttpOperationBody { @@ -452,6 +459,12 @@ export interface HttpOperationBody { * Type of the operation body. */ type: Type; + + /** If the body was explicitly set with `@body`. */ + readonly isExplicit: boolean; + + /** If the body contains metadata annotations to ignore. For example `@header`. */ + readonly containsMetadataAnnotations: boolean; } export interface HttpStatusCodeRange { diff --git a/packages/http/test/http-decorators.test.ts b/packages/http/test/http-decorators.test.ts index ab292519c71..66fcdbe9820 100644 --- a/packages/http/test/http-decorators.test.ts +++ b/packages/http/test/http-decorators.test.ts @@ -18,6 +18,8 @@ import { getStatusCodes, includeInapplicableMetadataInPayload, isBody, + isBodyIgnore, + isBodyRoot, isHeader, isPathParam, isQueryParam, @@ -420,6 +422,68 @@ describe("http: decorators", () => { }); }); + describe("@bodyRoot", () => { + it("emit diagnostics when @body is not used on model property", async () => { + const diagnostics = await runner.diagnose(` + @bodyRoot op test(): string; + + @bodyRoot model Foo {} + `); + + expectDiagnostics(diagnostics, [ + { + code: "decorator-wrong-target", + message: + "Cannot apply @bodyRoot decorator to test since it is not assignable to ModelProperty", + }, + { + code: "decorator-wrong-target", + message: + "Cannot apply @bodyRoot decorator to Foo since it is not assignable to ModelProperty", + }, + ]); + }); + + it("set the body root with @bodyRoot", async () => { + const { body } = (await runner.compile(` + @post op test(@test @bodyRoot body: string): string; + `)) as { body: ModelProperty }; + + ok(isBodyRoot(runner.program, body)); + }); + }); + + describe("@bodyIgnore", () => { + it("emit diagnostics when @body is not used on model property", async () => { + const diagnostics = await runner.diagnose(` + @bodyIgnore op test(): string; + + @bodyIgnore model Foo {} + `); + + expectDiagnostics(diagnostics, [ + { + code: "decorator-wrong-target", + message: + "Cannot apply @bodyIgnore decorator to test since it is not assignable to ModelProperty", + }, + { + code: "decorator-wrong-target", + message: + "Cannot apply @bodyIgnore decorator to Foo since it is not assignable to ModelProperty", + }, + ]); + }); + + it("isBodyIgnore returns true on property decorated", async () => { + const { body } = await runner.compile(` + @post op test(@test @bodyIgnore body: string): string; + `); + + ok(isBodyIgnore(runner.program, body as ModelProperty)); + }); + }); + describe("@statusCode", () => { it("emit diagnostics when @statusCode is not used on model property", async () => { const diagnostics = await runner.diagnose(` @@ -466,7 +530,7 @@ describe("http: decorators", () => { ` model CustomUnauthorizedResponse { @statusCode _: 401; - @body body: UnauthorizedResponse; + @bodyRoot body: UnauthorizedResponse; } model Pet { diff --git a/packages/http/test/parameters.test.ts b/packages/http/test/parameters.test.ts new file mode 100644 index 00000000000..24fcd345c73 --- /dev/null +++ b/packages/http/test/parameters.test.ts @@ -0,0 +1,202 @@ +import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing"; +import { deepStrictEqual } from "assert"; +import { describe, it } from "vitest"; +import { compileOperations, getRoutesFor } from "./test-host.js"; + +it("emit diagnostic for parameters with multiple http request annotations", async () => { + const [_, diagnostics] = await compileOperations(` + @get op get(@body body: string, @path @query multiParam: string): string; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/http/operation-param-duplicate-type", + message: "Param multiParam has multiple types: [query, path]", + }); +}); + +it("allows a deeply nested @body", async () => { + const routes = await getRoutesFor(` + op get(data: {nested: { @body param2: string }}): string; + `); + + deepStrictEqual(routes, [{ verb: "post", params: [], path: "/" }]); +}); +it("allows a deeply nested @bodyRoot", async () => { + const routes = await getRoutesFor(` + op get(data: {nested: { @bodyRoot param2: string }}): string; + `); + + deepStrictEqual(routes, [{ verb: "post", params: [], path: "/" }]); +}); + +it("emit diagnostic when there is an unannotated parameter and a @body param", async () => { + const [_, diagnostics] = await compileOperations(` + @get op get(param1: string, @body param2: string): string; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/http/duplicate-body", + message: + "Operation has a @body and an unannotated parameter. There can only be one representing the body", + }); +}); + +it("emit diagnostic when there are multiple @body param", async () => { + const [_, diagnostics] = await compileOperations(` + @get op get(@query select: string, @body param1: string, @body param2: string): string; + `); + + expectDiagnostics(diagnostics, [ + { + code: "@typespec/http/duplicate-body", + message: "Operation has multiple @body parameters declared", + }, + { + code: "@typespec/http/duplicate-body", + message: "Operation has multiple @body parameters declared", + }, + ]); +}); + +it("emit error if using multipart/form-data contentType parameter with a body not being a model", async () => { + const [_, diagnostics] = await compileOperations(` + @get op get(@header contentType: "multipart/form-data", @body body: string | int32): string; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/http/multipart-model", + message: "Multipart request body must be a model.", + }); +}); + +it("emit warning if using contentType parameter without a body", async () => { + const [_, diagnostics] = await compileOperations(` + + @get op get(@header contentType: "image/png"): string; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/http/content-type-ignored", + message: "`Content-Type` header ignored because there is no body.", + }); +}); + +it("resolve body when defined with @body", async () => { + const [routes, diagnostics] = await compileOperations(` + @get op get(@query select: string, @body bodyParam: string): string; + `); + + expectDiagnosticEmpty(diagnostics); + deepStrictEqual(routes, [ + { + verb: "get", + path: "/", + params: { params: [{ type: "query", name: "select" }], body: "bodyParam" }, + }, + ]); +}); + +it("resolves single unannotated parameter as request body", async () => { + const [routes, diagnostics] = await compileOperations(` + @get op get(@query select: string, unannotatedBodyParam: string): string; + `); + + expectDiagnosticEmpty(diagnostics); + deepStrictEqual(routes, [ + { + verb: "get", + path: "/", + params: { + params: [{ type: "query", name: "select" }], + body: ["unannotatedBodyParam"], + }, + }, + ]); +}); + +it("resolves multiple unannotated parameters as request body", async () => { + const [routes, diagnostics] = await compileOperations(` + @get op get( + @query select: string, + unannotatedBodyParam1: string, + unannotatedBodyParam2: string): string; + `); + + expectDiagnosticEmpty(diagnostics); + deepStrictEqual(routes, [ + { + verb: "get", + path: "/", + params: { + params: [{ type: "query", name: "select" }], + body: ["unannotatedBodyParam1", "unannotatedBodyParam2"], + }, + }, + ]); +}); + +it("resolves unannotated path parameters that are included in the route path", async () => { + const [routes, diagnostics] = await compileOperations(` + @route("/test/{name}/sub/{foo}") + @get op get( + name: string, + foo: string + ): string; + + @route("/nested/{name}") + namespace A { + @route("sub") + namespace B { + @route("{bar}") + @get op get( + name: string, + bar: string + ): string; + } + } + `); + + expectDiagnosticEmpty(diagnostics); + deepStrictEqual(routes, [ + { + verb: "get", + path: "/test/{name}/sub/{foo}", + params: { + params: [ + { type: "path", name: "name" }, + { type: "path", name: "foo" }, + ], + body: undefined, + }, + }, + { + verb: "get", + path: "/nested/{name}/sub/{bar}", + params: { + params: [ + { type: "path", name: "name" }, + { type: "path", name: "bar" }, + ], + body: undefined, + }, + }, + ]); +}); + +describe("emit diagnostics when using metadata decorator in @body", () => { + it.each([ + ["@header", "id: string"], + ["@query", "id: string"], + ["@path", "id: string"], + ])("%s", async (dec, prop) => { + const [_, diagnostics] = await compileOperations( + `op read(@body explicit: {${dec} ${prop}, other: string}): void;` + ); + expectDiagnostics(diagnostics, { code: "@typespec/http/metadata-ignored" }); + }); +}); + +it("doesn't emit diagnostic if the metadata is not applicable in the request", async () => { + const [_, diagnostics] = await compileOperations(`op read(@statusCode id: 200): { };`); + expectDiagnosticEmpty(diagnostics); +}); diff --git a/packages/http/test/responses.test.ts b/packages/http/test/responses.test.ts index 3632746f52f..bd830ae776e 100644 --- a/packages/http/test/responses.test.ts +++ b/packages/http/test/responses.test.ts @@ -4,29 +4,76 @@ import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, it } from "vitest"; import { compileOperations, getOperationsWithServiceNamespace } from "./test-host.js"; -describe("http: responses", () => { - it("issues diagnostics for duplicate body decorator", async () => { +describe("body resolution", () => { + it("emit diagnostics for duplicate @body decorator", async () => { const [_, diagnostics] = await compileOperations( - ` - model Foo { - foo: string; - } - model Bar { - bar: string; - } - @route("/") - namespace root { - @get - op read(): { @body body1: Foo, @body body2: Bar }; - } - ` + `op read(): { @body body1: string, @body body2: int32 };` + ); + expectDiagnostics(diagnostics, [ + { code: "@typespec/http/duplicate-body" }, + { code: "@typespec/http/duplicate-body" }, + ]); + }); + + it("emit diagnostics for duplicate @bodyRoot decorator", async () => { + const [_, diagnostics] = await compileOperations( + `op read(): { @bodyRoot body1: string, @bodyRoot body2: int32 };` ); - expectDiagnostics(diagnostics, [{ code: "@typespec/http/duplicate-body" }]); + expectDiagnostics(diagnostics, [ + { code: "@typespec/http/duplicate-body" }, + { code: "@typespec/http/duplicate-body" }, + ]); }); - it("issues diagnostics for invalid content types", async () => { + it("emit diagnostics for using @body and @bodyRoute decorator", async () => { const [_, diagnostics] = await compileOperations( - ` + `op read(): { @bodyRoot body1: string, @body body2: int32 };` + ); + expectDiagnostics(diagnostics, [ + { code: "@typespec/http/duplicate-body" }, + { code: "@typespec/http/duplicate-body" }, + ]); + }); + + it("allows a deeply nested @body", async () => { + const [_, diagnostics] = await compileOperations(` + op get(): {data: {nested: { @body param2: string }}}; + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("allows a deeply nested @bodyRoot", async () => { + const [_, diagnostics] = await compileOperations(` + op get(): {data: {nested: { @bodyRoot param2: string }}}; + `); + + expectDiagnosticEmpty(diagnostics); + }); + + describe("emit diagnostics when using metadata decorator in @body", () => { + it.each([ + ["@header", "id: string"], + ["@statusCode", "_: 200"], + ])("%s", async (dec, prop) => { + const [_, diagnostics] = await compileOperations( + `op read(): { @body explicit: {${dec} ${prop}, other: string} };` + ); + expectDiagnostics(diagnostics, { code: "@typespec/http/metadata-ignored" }); + }); + }); +}); + +it("doesn't emit diagnostic if the metadata is not applicable in the response", async () => { + const [_, diagnostics] = await compileOperations( + `op read(): { @body explicit: {@path id: string} };` + ); + expectDiagnosticEmpty(diagnostics); +}); + +it("issues diagnostics for invalid content types", async () => { + const [_, diagnostics] = await compileOperations( + ` model Foo { foo: string; } @@ -35,60 +82,56 @@ describe("http: responses", () => { contentType: "text/plain"; } - namespace root { - @route("/test1") - @get - op test1(): { @header contentType: string, @body body: Foo }; - @route("/test2") - @get - op test2(): { @header contentType: 42, @body body: Foo }; - @route("/test3") - @get - op test3(): { @header contentType: "application/json" | TextPlain, @body body: Foo }; - } + @route("/test1") + @get + op test1(): { @header contentType: string, @body body: Foo }; + @route("/test2") + @get + op test2(): { @header contentType: 42, @body body: Foo }; + @route("/test3") + @get + op test3(): { @header contentType: "application/json" | TextPlain, @body body: Foo }; ` - ); - expectDiagnostics(diagnostics, [ - { code: "@typespec/http/content-type-string" }, - { code: "@typespec/http/content-type-string" }, - { code: "@typespec/http/content-type-string" }, - ]); - }); + ); + expectDiagnostics(diagnostics, [ + { code: "@typespec/http/content-type-string" }, + { code: "@typespec/http/content-type-string" }, + { code: "@typespec/http/content-type-string" }, + ]); +}); - it("supports any casing for string literal 'Content-Type' header properties.", async () => { - const [routes, diagnostics] = await getOperationsWithServiceNamespace( - ` +it("supports any casing for string literal 'Content-Type' header properties.", async () => { + const [routes, diagnostics] = await getOperationsWithServiceNamespace( + ` model Foo {} - @test - namespace Test { - @route("/test1") - @get - op test1(): { @header "content-Type": "text/html", @body body: Foo }; + @route("/test1") + @get + op test1(): { @header "content-Type": "text/html", @body body: Foo }; - @route("/test2") - @get - op test2(): { @header "CONTENT-type": "text/plain", @body body: Foo }; + @route("/test2") + @get + op test2(): { @header "CONTENT-type": "text/plain", @body body: Foo }; - @route("/test3") - @get - op test3(): { @header "content-type": "application/json", @body body: Foo }; - } + @route("/test3") + @get + op test3(): { @header "content-type": "application/json", @body body: Foo }; ` - ); - expectDiagnosticEmpty(diagnostics); - strictEqual(routes.length, 3); - deepStrictEqual(routes[0].responses[0].responses[0].body?.contentTypes, ["text/html"]); - deepStrictEqual(routes[1].responses[0].responses[0].body?.contentTypes, ["text/plain"]); - deepStrictEqual(routes[2].responses[0].responses[0].body?.contentTypes, ["application/json"]); - }); + ); + expectDiagnosticEmpty(diagnostics); + strictEqual(routes.length, 3); + deepStrictEqual(routes[0].responses[0].responses[0].body?.contentTypes, ["text/html"]); + deepStrictEqual(routes[1].responses[0].responses[0].body?.contentTypes, ["text/plain"]); + deepStrictEqual(routes[2].responses[0].responses[0].body?.contentTypes, ["application/json"]); +}); - // Regression test for https://github.com/microsoft/typespec/issues/328 - it("empty response model becomes body if it has children", async () => { - const [routes, diagnostics] = await getOperationsWithServiceNamespace( - ` - @route("/") op read(): A; +// Regression test for https://github.com/microsoft/typespec/issues/328 +it("empty response model becomes body if it has children", async () => { + const [routes, diagnostics] = await getOperationsWithServiceNamespace( + ` + op read(): A; + @discriminator("foo") model A {} model B extends A { @@ -102,14 +145,13 @@ describe("http: responses", () => { } ` - ); - expectDiagnosticEmpty(diagnostics); - strictEqual(routes.length, 1); - const responses = routes[0].responses; - strictEqual(responses.length, 1); - const response = responses[0]; - const body = response.responses[0].body; - ok(body); - strictEqual((body.type as Model).name, "A"); - }); + ); + expectDiagnosticEmpty(diagnostics); + strictEqual(routes.length, 1); + const responses = routes[0].responses; + strictEqual(responses.length, 1); + const response = responses[0]; + const body = response.responses[0].body; + ok(body); + strictEqual((body.type as Model).name, "A"); }); diff --git a/packages/http/test/routes.test.ts b/packages/http/test/routes.test.ts index 3f1bc0a41d0..055c05688f8 100644 --- a/packages/http/test/routes.test.ts +++ b/packages/http/test/routes.test.ts @@ -310,174 +310,6 @@ describe("http: routes", () => { strictEqual(diagnostics[1].message, `Duplicate operation "get2" routed at "get /test".`); }); - describe("operation parameters", () => { - it("emit diagnostic for parameters with multiple http request annotations", async () => { - const [_, diagnostics] = await compileOperations(` - @route("/test") - @get op get(@body body: string, @path @query multiParam: string): string; - `); - - expectDiagnostics(diagnostics, { - code: "@typespec/http/operation-param-duplicate-type", - message: "Param multiParam has multiple types: [query, path]", - }); - }); - - it("emit diagnostic when there is an unannotated parameter and a @body param", async () => { - const [_, diagnostics] = await compileOperations(` - @route("/test") - @get op get(param1: string, @body param2: string): string; - `); - - expectDiagnostics(diagnostics, { - code: "@typespec/http/duplicate-body", - message: - "Operation has a @body and an unannotated parameter. There can only be one representing the body", - }); - }); - - it("emit diagnostic when there are multiple @body param", async () => { - const [_, diagnostics] = await compileOperations(` - @route("/test") - @get op get(@query select: string, @body param1: string, @body param2: string): string; - `); - - expectDiagnostics(diagnostics, { - code: "@typespec/http/duplicate-body", - message: "Operation has multiple @body parameters declared", - }); - }); - - it("emit error if using multipart/form-data contentType parameter with a body not being a model", async () => { - const [_, diagnostics] = await compileOperations(` - @route("/test") - @get op get(@header contentType: "multipart/form-data", @body body: string | int32): string; - `); - - expectDiagnostics(diagnostics, { - code: "@typespec/http/multipart-model", - message: "Multipart request body must be a model.", - }); - }); - - it("emit warning if using contentType parameter without a body", async () => { - const [_, diagnostics] = await compileOperations(` - @route("/test") - @get op get(@header contentType: "image/png"): string; - `); - - expectDiagnostics(diagnostics, { - code: "@typespec/http/content-type-ignored", - message: "`Content-Type` header ignored because there is no body.", - }); - }); - - it("resolve body when defined with @body", async () => { - const [routes, diagnostics] = await compileOperations(` - @route("/test") - @get op get(@query select: string, @body bodyParam: string): string; - `); - - expectDiagnosticEmpty(diagnostics); - deepStrictEqual(routes, [ - { - verb: "get", - path: "/test", - params: { params: [{ type: "query", name: "select" }], body: "bodyParam" }, - }, - ]); - }); - - it("resolves single unannotated parameter as request body", async () => { - const [routes, diagnostics] = await compileOperations(` - @route("/test") - @get op get(@query select: string, unannotatedBodyParam: string): string; - `); - - expectDiagnosticEmpty(diagnostics); - deepStrictEqual(routes, [ - { - verb: "get", - path: "/test", - params: { - params: [{ type: "query", name: "select" }], - body: ["unannotatedBodyParam"], - }, - }, - ]); - }); - - it("resolves multiple unannotated parameters as request body", async () => { - const [routes, diagnostics] = await compileOperations(` - @route("/test") - @get op get( - @query select: string, - unannotatedBodyParam1: string, - unannotatedBodyParam2: string): string; - `); - - expectDiagnosticEmpty(diagnostics); - deepStrictEqual(routes, [ - { - verb: "get", - path: "/test", - params: { - params: [{ type: "query", name: "select" }], - body: ["unannotatedBodyParam1", "unannotatedBodyParam2"], - }, - }, - ]); - }); - - it("resolves unannotated path parameters that are included in the route path", async () => { - const [routes, diagnostics] = await compileOperations(` - @route("/test/{name}/sub/{foo}") - @get op get( - name: string, - foo: string - ): string; - - @route("/nested/{name}") - namespace A { - @route("sub") - namespace B { - @route("{bar}") - @get op get( - name: string, - bar: string - ): string; - } - } - `); - - expectDiagnosticEmpty(diagnostics); - deepStrictEqual(routes, [ - { - verb: "get", - path: "/test/{name}/sub/{foo}", - params: { - params: [ - { type: "path", name: "name" }, - { type: "path", name: "foo" }, - ], - body: undefined, - }, - }, - { - verb: "get", - path: "/nested/{name}/sub/{bar}", - params: { - params: [ - { type: "path", name: "name" }, - { type: "path", name: "bar" }, - ], - body: undefined, - }, - }, - ]); - }); - }); - describe("double @route", () => { it("emit diagnostic if specifying route twice on operation", async () => { const [_, diagnostics] = await compileOperations(` diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 335aecedb59..8b45badd1ca 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -182,12 +182,6 @@ export const libDef = { default: `OpenAPI does not allow paths containing a query string.`, }, }, - "duplicate-body": { - severity: "error", - messages: { - default: "Duplicate @body declarations on response type", - }, - }, "duplicate-header": { severity: "error", messages: { diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 7b0bff8935b..20e1355bcde 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1019,7 +1019,12 @@ function createOAPIEmitter( const isBinary = isBinaryPayload(data.body.type, contentType); const schema = isBinary ? { type: "string", format: "binary" } - : getSchemaForBody(data.body.type, Visibility.Read, undefined); + : getSchemaForBody( + data.body.type, + Visibility.Read, + data.body.isExplicit && data.body.containsMetadataAnnotations, + undefined + ); if (schemaMap.has(contentType)) { schemaMap.get(contentType)!.push(schema); } else { @@ -1052,9 +1057,15 @@ function createOAPIEmitter( function callSchemaEmitter( type: Type, visibility: Visibility, + ignoreMetadataAnnotations?: boolean, contentType?: string ): Refable { - const result = emitTypeWithSchemaEmitter(type, visibility, contentType); + const result = emitTypeWithSchemaEmitter( + type, + visibility, + ignoreMetadataAnnotations, + contentType + ); switch (result.kind) { case "code": @@ -1076,7 +1087,7 @@ function createOAPIEmitter( } function getSchemaValue(type: Type, visibility: Visibility, contentType: string): OpenAPI3Schema { - const result = emitTypeWithSchemaEmitter(type, visibility, contentType); + const result = emitTypeWithSchemaEmitter(type, visibility, false, contentType); switch (result.kind) { case "code": @@ -1099,6 +1110,7 @@ function createOAPIEmitter( function emitTypeWithSchemaEmitter( type: Type, visibility: Visibility, + ignoreMetadataAnnotations?: boolean, contentType?: string ): EmitEntity { if (!metadataInfo.isTransformed(type, visibility)) { @@ -1106,17 +1118,28 @@ function createOAPIEmitter( } contentType = contentType === "application/json" ? undefined : contentType; return schemaEmitter.emitType(type, { - referenceContext: { visibility, serviceNamespaceName: serviceNamespaceName, contentType }, + referenceContext: { + visibility, + serviceNamespaceName: serviceNamespaceName, + ignoreMetadataAnnotations: ignoreMetadataAnnotations ?? false, + contentType, + }, }) as any; } function getSchemaForBody( type: Type, visibility: Visibility, + ignoreMetadataAnnotations: boolean, multipart: string | undefined ): any { const effectiveType = metadataInfo.getEffectivePayloadType(type, visibility); - return callSchemaEmitter(effectiveType, visibility, multipart ?? "application/json"); + return callSchemaEmitter( + effectiveType, + visibility, + ignoreMetadataAnnotations, + multipart ?? "application/json" + ); } function getParamPlaceholder(property: ModelProperty) { @@ -1194,6 +1217,7 @@ function createOAPIEmitter( : getSchemaForBody( body.type, visibility, + body.isExplicit, contentType.startsWith("multipart/") ? contentType : undefined ); if (schemaMap.has(contentType)) { @@ -1236,6 +1260,7 @@ function createOAPIEmitter( : getSchemaForBody( body.type, visibility, + body.isExplicit && body.containsMetadataAnnotations, contentType.startsWith("multipart/") ? contentType : undefined ); const contentEntry: any = { @@ -1556,7 +1581,7 @@ function createOAPIEmitter( const values = getKnownValues(program, typespecType as any); if (values) { return { - oneOf: [newTarget, callSchemaEmitter(values, Visibility.Read, "application/json")], + oneOf: [newTarget, callSchemaEmitter(values, Visibility.Read, false, "application/json")], }; } @@ -1575,6 +1600,7 @@ function createOAPIEmitter( const newType = callSchemaEmitter( encodeData.type, Visibility.Read, + false, "application/json" ) as OpenAPI3Schema; newTarget.type = newType.type; diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index e25c9f1a0d6..dbcf9546ce8 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -197,6 +197,10 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< return this.emitter.getContext().visibility ?? Visibility.Read; } + #ignoreMetadataAnnotations(): boolean { + return this.emitter.getContext().ignoreMetadataAnnotations; + } + #getContentType(): string { return this.emitter.getContext().contentType ?? "application/json"; } @@ -263,7 +267,10 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< // If the property has a type of 'never', don't include it in the schema continue; } - if (!this.#metadataInfo.isPayloadProperty(prop, visibility)) { + + if ( + !this.#metadataInfo.isPayloadProperty(prop, visibility, this.#ignoreMetadataAnnotations()) + ) { continue; } @@ -292,7 +299,9 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< // If the property has a type of 'never', don't include it in the schema continue; } - if (!this.#metadataInfo.isPayloadProperty(prop, visibility)) { + if ( + !this.#metadataInfo.isPayloadProperty(prop, visibility, this.#ignoreMetadataAnnotations()) + ) { continue; } const result = this.emitter.emitModelProperty(prop); @@ -844,6 +853,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< } const usage = this.#visibilityUsage.getUsage(type); + const shouldAddSuffix = usage !== undefined && usage.size > 1; const visibility = this.#getVisibilityContext(); const fullName = diff --git a/packages/openapi3/src/visibility-usage.ts b/packages/openapi3/src/visibility-usage.ts index 4cfe40ceba9..152d3c2753c 100644 --- a/packages/openapi3/src/visibility-usage.ts +++ b/packages/openapi3/src/visibility-usage.ts @@ -175,6 +175,9 @@ function navigateReferencedTypes( navigateReferencedTypes(type.baseModel, usage, callback, visited); } navigateIterable(type.derivedModels, usage, callback, visited); + if (type.baseModel) { + navigateReferencedTypes(type.baseModel, usage, callback, visited); + } if (type.indexer) { if (type.indexer.key.name === "integer") { navigateReferencedTypes(type.indexer.value, usage | Visibility.Item, callback, visited); diff --git a/packages/openapi3/test/metadata.test.ts b/packages/openapi3/test/metadata.test.ts index 87bf9ea39e2..3a0bbdd85e0 100644 --- a/packages/openapi3/test/metadata.test.ts +++ b/packages/openapi3/test/metadata.test.ts @@ -612,7 +612,7 @@ describe("openapi3: metadata", () => { @header h: string; } @route("/single") @get op single(...Parameters): string; - @route("/batch") @get op batch(...Body): string; + @route("/batch") @get op batch(@bodyRoot _: Parameters[]): string; ` ); deepStrictEqual(res.paths, { @@ -643,7 +643,6 @@ describe("openapi3: metadata", () => { }, }, requestBody: { - description: "The body type of the operation request or response.", required: true, content: { "application/json": { @@ -766,7 +765,7 @@ describe("openapi3: metadata", () => { @path p: string; @header h: string; } - @route("/batch") @post op batch(@body body?: Parameters[]): string; + @route("/batch") @post op batch(@bodyRoot body?: Parameters[]): string; ` ); deepStrictEqual(res.paths, { @@ -926,7 +925,7 @@ describe("openapi3: metadata", () => { }); }); - it("supports nested metadata and removes emptied properties", async () => { + it("supports nested metadata", async () => { const res = await openApiFor( ` model Pet { @@ -1016,15 +1015,33 @@ describe("openapi3: metadata", () => { PetCreate: { type: "object", properties: { + headers: { + type: "object", + properties: { + moreHeaders: { + type: "object", + }, + }, + required: ["moreHeaders"], + }, name: { type: "string", }, }, - required: ["name"], + required: ["headers", "name"], }, Pet: { type: "object", properties: { + headers: { + type: "object", + properties: { + moreHeaders: { + type: "object", + }, + }, + required: ["moreHeaders"], + }, id: { type: "string", }, @@ -1032,7 +1049,7 @@ describe("openapi3: metadata", () => { type: "string", }, }, - required: ["id", "name"], + required: ["headers", "id", "name"], }, }); }); diff --git a/packages/openapi3/test/parameters.test.ts b/packages/openapi3/test/parameters.test.ts index 8482ace4576..ff4e2be2eea 100644 --- a/packages/openapi3/test/parameters.test.ts +++ b/packages/openapi3/test/parameters.test.ts @@ -306,6 +306,73 @@ describe("openapi3: parameters", () => { strictEqual(res.paths["/"].post.requestBody, undefined); }); + it("using @body ignore any metadata property underneath", async () => { + const res = await openApiFor(`@get op read( + @body body: { + #suppress "@typespec/http/metadata-ignored" + @header header: string, + #suppress "@typespec/http/metadata-ignored" + @query query: string, + #suppress "@typespec/http/metadata-ignored" + @statusCode code: 201, + } + ): void;`); + expect(res.paths["/"].get.requestBody.content["application/json"].schema).toEqual({ + type: "object", + properties: { + header: { type: "string" }, + query: { type: "string" }, + code: { type: "number", enum: [201] }, + }, + required: ["header", "query", "code"], + }); + }); + + describe("request parameters resolving to no property in the body produce no body", () => { + it.each(["()", "(@header prop: string)", `(@visibility("none") prop: string)`])( + "%s", + async (params) => { + const res = await openApiFor(`op test${params}: void;`); + strictEqual(res.paths["/"].get.requestBody, undefined); + } + ); + }); + + it("property in body with only metadata properties should still be included", async () => { + const res = await openApiFor(`op read( + headers: { + @header header1: string; + @header header2: string; + }; + name: string; + ): void;`); + expect(res.paths["/"].post.requestBody.content["application/json"].schema).toEqual({ + type: "object", + properties: { + headers: { type: "object" }, + name: { type: "string" }, + }, + required: ["headers", "name"], + }); + }); + + it("property in body with only metadata properties and @bodyIgnore should not be included", async () => { + const res = await openApiFor(`op read( + @bodyIgnore headers: { + @header header1: string; + @header header2: string; + }; + name: string; + ): void;`); + expect(res.paths["/"].post.requestBody.content["application/json"].schema).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }); + }); + describe("content type parameter", () => { it("header named with 'Content-Type' gets resolved as content type for operation.", async () => { const res = await openApiFor( diff --git a/packages/openapi3/test/return-types.test.ts b/packages/openapi3/test/return-types.test.ts index 09cd6f3d936..11ad9c854c5 100644 --- a/packages/openapi3/test/return-types.test.ts +++ b/packages/openapi3/test/return-types.test.ts @@ -1,9 +1,27 @@ import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { checkFor, openApiFor } from "./test-host.js"; describe("openapi3: return types", () => { + it("model used with @body and without shouldn't conflict if it contains no metadata", async () => { + const res = await openApiFor( + ` + model Foo { + name: string; + } + @route("c1") op c1(): Foo; + @route("c2") op c2(): {@body _: Foo}; + ` + ); + deepStrictEqual(res.paths["/c1"].get.responses["200"].content["application/json"].schema, { + $ref: "#/components/schemas/Foo", + }); + deepStrictEqual(res.paths["/c2"].get.responses["200"].content["application/json"].schema, { + $ref: "#/components/schemas/Foo", + }); + }); + it("defines responses with response headers", async () => { const res = await openApiFor( ` @@ -89,9 +107,9 @@ describe("openapi3: return types", () => { @put op create(): {@header eTag: string}; ` ); - ok(res.paths["/"].put.responses["204"]); - ok(res.paths["/"].put.responses["204"].headers["e-tag"]); - strictEqual(res.paths["/"].put.responses["204"].headers["e-tag"].required, true); + ok(res.paths["/"].put.responses["200"]); + ok(res.paths["/"].put.responses["200"].headers["e-tag"]); + strictEqual(res.paths["/"].put.responses["200"].headers["e-tag"].required, true); }); it("optional response header are marked required: false", async () => { @@ -100,9 +118,9 @@ describe("openapi3: return types", () => { @put op create(): {@header eTag?: string}; ` ); - ok(res.paths["/"].put.responses["204"]); - ok(res.paths["/"].put.responses["204"].headers["e-tag"]); - strictEqual(res.paths["/"].put.responses["204"].headers["e-tag"].required, false); + ok(res.paths["/"].put.responses["200"]); + ok(res.paths["/"].put.responses["200"].headers["e-tag"]); + strictEqual(res.paths["/"].put.responses["200"].headers["e-tag"].required, false); }); it("defines responses with headers and status codes in base model", async () => { @@ -258,42 +276,6 @@ describe("openapi3: return types", () => { ); }); - it("return type with no properties should be 200 with empty object as type", async () => { - const res = await openApiFor( - ` - @get op test(): {}; - ` - ); - - const responses = res.paths["/"].get.responses; - ok(responses["200"]); - deepStrictEqual(responses["200"].content, { - "application/json": { - schema: { - type: "object", - }, - }, - }); - }); - - it("{} return type should produce 200 ", async () => { - const res = await openApiFor( - ` - @get op test(): {}; - ` - ); - - const responses = res.paths["/"].get.responses; - ok(responses["200"]); - deepStrictEqual(responses["200"].content, { - "application/json": { - schema: { - type: "object", - }, - }, - }); - }); - it("produce additionalProperties schema if response is Record", async () => { const res = await openApiFor( ` @@ -321,20 +303,9 @@ describe("openapi3: return types", () => { `); const responses = res.paths["/"].get.responses; - ok(responses["204"]); - ok(responses["204"].content === undefined, "response should have no content"); - ok(responses["200"] === undefined); - }); - - it("defaults status code to 204 when implicit body has no content", async () => { - const res = await openApiFor(` - @delete op delete(): { @header date: string }; - `); - const responses = res.paths["/"].delete.responses; - ok(responses["200"] === undefined); - ok(responses["204"]); - ok(responses["204"].headers["date"]); - ok(responses["204"].content === undefined); + ok(responses["200"]); + ok(responses["200"].content === undefined, "response should have no content"); + ok(responses["204"] === undefined); }); it("defaults status code to default when model has @error decorator", async () => { @@ -453,9 +424,76 @@ describe("openapi3: return types", () => { ok(res.paths["/"].get.responses["204"]); }); - it("defaults to 204 no content with void @body", async () => { + it("defaults to 200 no content with void @body", async () => { const res = await openApiFor(`@get op read(): {@body body: void};`); - ok(res.paths["/"].get.responses["204"]); + ok(res.paths["/"].get.responses["200"]); + }); + + it("using @body ignore any metadata property underneath", async () => { + const res = await openApiFor(`@get op read(): { + @body body: { + #suppress "@typespec/http/metadata-ignored" + @header header: string, + #suppress "@typespec/http/metadata-ignored" + @query query: string, + #suppress "@typespec/http/metadata-ignored" + @statusCode code: 201, + } + };`); + expect(res.paths["/"].get.responses["200"].content["application/json"].schema).toEqual({ + type: "object", + properties: { + header: { type: "string" }, + query: { type: "string" }, + code: { type: "number", enum: [201] }, + }, + required: ["header", "query", "code"], + }); + }); + + describe("response model resolving to no property in the body produce no body", () => { + it.each(["{}", "{@header prop: string}", `{@visibility("none") prop: string}`])( + "%s", + async (body) => { + const res = await openApiFor(`op test(): ${body};`); + strictEqual(res.paths["/"].get.responses["200"].content, undefined); + } + ); + }); + + it("property in body with only metadata properties should still be included", async () => { + const res = await openApiFor(`op read(): { + headers: { + @header header1: string; + @header header2: string; + }; + name: string; + };`); + expect(res.paths["/"].get.responses["200"].content["application/json"].schema).toEqual({ + type: "object", + properties: { + headers: { type: "object" }, + name: { type: "string" }, + }, + required: ["headers", "name"], + }); + }); + + it("property in body with only metadata properties and @bodyIgnore should not be included", async () => { + const res = await openApiFor(`op read(): { + @bodyIgnore headers: { + @header header1: string; + @header header2: string; + }; + name: string; + };`); + expect(res.paths["/"].get.responses["200"].content["application/json"].schema).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }); }); describe("multiple content types", () => { diff --git a/packages/openapi3/tsconfig.json b/packages/openapi3/tsconfig.json index ba35695464d..04002b8509f 100644 --- a/packages/openapi3/tsconfig.json +++ b/packages/openapi3/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "references": [ { "path": "../compiler/tsconfig.json" }, - { "path": "../rest/tsconfig.json" }, + { "path": "../http/tsconfig.json" }, { "path": "../openapi/tsconfig.json" } ], "compilerOptions": { diff --git a/packages/rest/lib/resource.tsp b/packages/rest/lib/resource.tsp index 7f3da7cf19a..25f87ebd031 100644 --- a/packages/rest/lib/resource.tsp +++ b/packages/rest/lib/resource.tsp @@ -83,7 +83,7 @@ interface ResourceRead { @doc("Resource create operation completed successfully.") model ResourceCreatedResponse { ...CreatedResponse; - @body body: Resource; + @bodyRoot body: Resource; } /** @@ -104,7 +104,7 @@ interface ResourceCreateOrReplace { @createsOrReplacesResource(Resource) createOrReplace( ...ResourceParameters, - @body resource: ResourceCreateModel, + @bodyRoot resource: ResourceCreateModel, ): Resource | ResourceCreatedResponse | Error; } @@ -135,7 +135,7 @@ interface ResourceCreateOrUpdate { @createsOrUpdatesResource(Resource) createOrUpdate( ...ResourceParameters, - @body resource: ResourceCreateOrUpdateModel, + @bodyRoot resource: ResourceCreateOrUpdateModel, ): Resource | ResourceCreatedResponse | Error; } @@ -166,7 +166,7 @@ interface ResourceCreate { @createsResource(Resource) create( ...ResourceCollectionParameters, - @body resource: ResourceCreateModel, + @bodyRoot resource: ResourceCreateModel, ): Resource | ResourceCreatedResponse | Error; } @@ -190,7 +190,7 @@ interface ResourceUpdate { @updatesResource(Resource) update( ...ResourceParameters, - @body properties: ResourceCreateOrUpdateModel, + @bodyRoot properties: ResourceCreateOrUpdateModel, ): Resource | Error; } @@ -400,7 +400,7 @@ interface ExtensionResourceCreateOrUpdate, ...ResourceParameters, - @body resource: ResourceCreateOrUpdateModel, + @bodyRoot resource: ResourceCreateOrUpdateModel, ): Extension | ResourceCreatedResponse | Error; } @@ -423,7 +423,7 @@ interface ExtensionResourceCreate, - @body resource: ResourceCreateModel, + @bodyRoot resource: ResourceCreateModel, ): Extension | ResourceCreatedResponse | Error; }