From 40df1ec9a307a6bde664b2ad08c4f823d6956cc0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 3 Jun 2024 15:04:33 -0700 Subject: [PATCH] Multipart explicit parts (#3342) resolve #3046 [Playground](https://cadlplayground.z22.web.core.windows.net/prs/3342/) Add the following: - `@multipartBody` decorator - `File` type - `HttpPart` type Had to do a decent amount of refactoring to be able to reuse the body parsing, this result in a much cleaner resolution of the body and other metadata properties across request, response and metadata. The way it works now is instead of calling `gatherMetadata` that would just get the properties that are metadata but also ones with `@body` and `@bodyRoot` we now call a `resolveHtpProperties`, this does the same resolution in term of filtering properties but it also figure out what is the kind of property in the concept of http(header, query, body, etc.) this leaves the error resolution to this function for duplicate annotations. What is nice is now we don't need to keep asking oh is this a query or a header or a body we can just check the kind of `HttpProperty` also resolve #1311 --- ...feature-multipart-v2-2024-4-14-16-27-48.md | 6 + ...feature-multipart-v2-2024-4-14-22-58-52.md | 15 + .../feature-multipart-v2-2024-4-14-23-5-59.md | 8 + .vscode/settings.json | 1 + docs/libraries/http/reference/data-types.md | 43 ++ docs/libraries/http/reference/decorators.md | 26 + docs/libraries/http/reference/index.mdx | 4 + packages/http/README.md | 27 ++ .../generated-defs/TypeSpec.Http.Private.ts | 11 +- packages/http/generated-defs/TypeSpec.Http.ts | 17 + .../generated-defs/TypeSpec.Http.ts-test.ts | 4 + .../{http-decorators.tsp => decorators.tsp} | 22 +- packages/http/lib/http.tsp | 18 +- packages/http/lib/private.decorators.tsp | 14 + packages/http/src/body.ts | 174 ------- packages/http/src/content-types.ts | 3 + packages/http/src/decorators.ts | 44 +- packages/http/src/http-property.ts | 220 +++++++++ packages/http/src/index.ts | 1 + packages/http/src/lib.ts | 29 ++ packages/http/src/metadata.ts | 81 +--- packages/http/src/parameters.ts | 155 ++---- packages/http/src/payload.ts | 444 ++++++++++++++++++ packages/http/src/private.decorators.ts | 115 +++++ packages/http/src/responses.ts | 93 +--- packages/http/src/types.ts | 81 ++-- packages/http/test/multipart.test.ts | 199 ++++++++ packages/http/test/responses.test.ts | 5 +- packages/http/test/test-host.ts | 2 +- packages/openapi3/src/openapi.ts | 210 +++++++-- packages/openapi3/src/schema-emitter.ts | 19 +- packages/openapi3/test/multipart.test.ts | 212 ++++++++- packages/rest/test/test-host.ts | 2 +- packages/samples/specs/multipart/main.tsp | 13 +- .../rest-metadata-emitter-sample.ts | 5 +- .../multipart/@typespec/openapi3/openapi.yaml | 25 +- vitest.workspace.ts | 10 +- 37 files changed, 1773 insertions(+), 585 deletions(-) create mode 100644 .chronus/changes/feature-multipart-v2-2024-4-14-16-27-48.md create mode 100644 .chronus/changes/feature-multipart-v2-2024-4-14-22-58-52.md create mode 100644 .chronus/changes/feature-multipart-v2-2024-4-14-23-5-59.md rename packages/http/lib/{http-decorators.tsp => decorators.tsp} (96%) create mode 100644 packages/http/lib/private.decorators.tsp delete mode 100644 packages/http/src/body.ts create mode 100644 packages/http/src/http-property.ts create mode 100644 packages/http/src/payload.ts create mode 100644 packages/http/src/private.decorators.ts create mode 100644 packages/http/test/multipart.test.ts diff --git a/.chronus/changes/feature-multipart-v2-2024-4-14-16-27-48.md b/.chronus/changes/feature-multipart-v2-2024-4-14-16-27-48.md new file mode 100644 index 0000000000..cfd31b34e2 --- /dev/null +++ b/.chronus/changes/feature-multipart-v2-2024-4-14-16-27-48.md @@ -0,0 +1,6 @@ +--- +changeKind: internal +packages: + - "@typespec/rest" +--- + diff --git a/.chronus/changes/feature-multipart-v2-2024-4-14-22-58-52.md b/.chronus/changes/feature-multipart-v2-2024-4-14-22-58-52.md new file mode 100644 index 0000000000..a082e13d3e --- /dev/null +++ b/.chronus/changes/feature-multipart-v2-2024-4-14-22-58-52.md @@ -0,0 +1,15 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/http" +--- + +Add new multipart handling. Using `@multipartBody` with `HttpPart`. See [multipart docs] for more information https://typespec.io/docs/next/libraries/http/multipart + + ```tsp + op upload(@header contentType: "multipart/mixed", @multipartBody body: { + name: HttpPart; + avatar: HttpPart[]; + }): void; + ``` diff --git a/.chronus/changes/feature-multipart-v2-2024-4-14-23-5-59.md b/.chronus/changes/feature-multipart-v2-2024-4-14-23-5-59.md new file mode 100644 index 0000000000..e84b41362f --- /dev/null +++ b/.chronus/changes/feature-multipart-v2-2024-4-14-23-5-59.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Add support for new multipart constructs in http library diff --git a/.vscode/settings.json b/.vscode/settings.json index f8e1479646..8e894d52f4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,7 @@ "**/node_modules/**": true, "packages/compiler/templates/__snapshots__/**": true, "packages/website/versioned_docs/**": true, + "packages/http-client-csharp/**/Generated/**": true, "packages/samples/scratch/**": false // Those files are in gitignore but we still want to search for them }, "files.exclude": { diff --git a/docs/libraries/http/reference/data-types.md b/docs/libraries/http/reference/data-types.md index 109a8f8373..dc8106701e 100644 --- a/docs/libraries/http/reference/data-types.md +++ b/docs/libraries/http/reference/data-types.md @@ -205,6 +205,20 @@ model TypeSpec.Http.CreatedResponse | ---------- | ----- | ---------------- | | statusCode | `201` | The status code. | +### `File` {#TypeSpec.Http.File} + +```typespec +model TypeSpec.Http.File +``` + +#### Properties + +| Name | Type | Description | +| ------------ | -------- | ----------- | +| contentType? | `string` | | +| filename? | `string` | | +| contents | `bytes` | | + ### `ForbiddenResponse` {#TypeSpec.Http.ForbiddenResponse} Access is forbidden. @@ -234,6 +248,35 @@ model TypeSpec.Http.HeaderOptions | name? | `string` | Name of the header when sent over HTTP. | | format? | `"csv" \| "multi" \| "tsv" \| "ssv" \| "pipes" \| "simple" \| "form"` | Determines the format of the array if type array is used. | +### `HttpPart` {#TypeSpec.Http.HttpPart} + +```typespec +model TypeSpec.Http.HttpPart +``` + +#### Template Parameters + +| Name | Description | +| ------- | ----------- | +| Type | | +| Options | | + +#### Properties + +None + +### `HttpPartOptions` {#TypeSpec.Http.HttpPartOptions} + +```typespec +model TypeSpec.Http.HttpPartOptions +``` + +#### Properties + +| Name | Type | Description | +| ----- | -------- | ------------------------------------------- | +| name? | `string` | Name of the part when using the array form. | + ### `ImplicitFlow` {#TypeSpec.Http.ImplicitFlow} Implicit flow diff --git a/docs/libraries/http/reference/decorators.md b/docs/libraries/http/reference/decorators.md index 444953ea78..26c688c55b 100644 --- a/docs/libraries/http/reference/decorators.md +++ b/docs/libraries/http/reference/decorators.md @@ -225,6 +225,32 @@ Specify if inapplicable metadata should be included in the payload for the given | ----- | ----------------- | --------------------------------------------------------------- | | value | `valueof boolean` | If true, inapplicable metadata will be included in the payload. | +### `@multipartBody` {#@TypeSpec.Http.multipartBody} + +```typespec +@TypeSpec.Http.multipartBody +``` + +#### Target + +`ModelProperty` + +#### Parameters + +None + +#### Examples + +```tsp +op upload( + @header `content-type`: "multipart/form-data", + @multipartBody body: { + fullName: HttpPart; + headShots: HttpPart[]; + }, +): void; +``` + ### `@patch` {#@TypeSpec.Http.patch} Specify the HTTP verb for the target operation to be `PATCH`. diff --git a/docs/libraries/http/reference/index.mdx b/docs/libraries/http/reference/index.mdx index ec9a88f52e..d85da81df6 100644 --- a/docs/libraries/http/reference/index.mdx +++ b/docs/libraries/http/reference/index.mdx @@ -43,6 +43,7 @@ npm install --save-peer @typespec/http - [`@head`](./decorators.md#@TypeSpec.Http.head) - [`@header`](./decorators.md#@TypeSpec.Http.header) - [`@includeInapplicableMetadataInPayload`](./decorators.md#@TypeSpec.Http.includeInapplicableMetadataInPayload) +- [`@multipartBody`](./decorators.md#@TypeSpec.Http.multipartBody) - [`@patch`](./decorators.md#@TypeSpec.Http.patch) - [`@path`](./decorators.md#@TypeSpec.Http.path) - [`@post`](./decorators.md#@TypeSpec.Http.post) @@ -66,8 +67,11 @@ npm install --save-peer @typespec/http - [`ClientCredentialsFlow`](./data-types.md#TypeSpec.Http.ClientCredentialsFlow) - [`ConflictResponse`](./data-types.md#TypeSpec.Http.ConflictResponse) - [`CreatedResponse`](./data-types.md#TypeSpec.Http.CreatedResponse) +- [`File`](./data-types.md#TypeSpec.Http.File) - [`ForbiddenResponse`](./data-types.md#TypeSpec.Http.ForbiddenResponse) - [`HeaderOptions`](./data-types.md#TypeSpec.Http.HeaderOptions) +- [`HttpPart`](./data-types.md#TypeSpec.Http.HttpPart) +- [`HttpPartOptions`](./data-types.md#TypeSpec.Http.HttpPartOptions) - [`ImplicitFlow`](./data-types.md#TypeSpec.Http.ImplicitFlow) - [`LocationHeader`](./data-types.md#TypeSpec.Http.LocationHeader) - [`MovedResponse`](./data-types.md#TypeSpec.Http.MovedResponse) diff --git a/packages/http/README.md b/packages/http/README.md index 2d4e670067..488f8d7855 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -44,6 +44,7 @@ Available ruleSets: - [`@head`](#@head) - [`@header`](#@header) - [`@includeInapplicableMetadataInPayload`](#@includeinapplicablemetadatainpayload) +- [`@multipartBody`](#@multipartbody) - [`@patch`](#@patch) - [`@path`](#@path) - [`@post`](#@post) @@ -272,6 +273,32 @@ Specify if inapplicable metadata should be included in the payload for the given | ----- | ----------------- | --------------------------------------------------------------- | | value | `valueof boolean` | If true, inapplicable metadata will be included in the payload. | +#### `@multipartBody` + +```typespec +@TypeSpec.Http.multipartBody +``` + +##### Target + +`ModelProperty` + +##### Parameters + +None + +##### Examples + +```tsp +op upload( + @header `content-type`: "multipart/form-data", + @multipartBody body: { + fullName: HttpPart; + headShots: HttpPart[]; + }, +): void; +``` + #### `@patch` Specify the HTTP verb for the target operation to be `PATCH`. diff --git a/packages/http/generated-defs/TypeSpec.Http.Private.ts b/packages/http/generated-defs/TypeSpec.Http.Private.ts index c1bbdcf252..c78126bd7a 100644 --- a/packages/http/generated-defs/TypeSpec.Http.Private.ts +++ b/packages/http/generated-defs/TypeSpec.Http.Private.ts @@ -1,3 +1,12 @@ -import type { DecoratorContext, Model } from "@typespec/compiler"; +import type { DecoratorContext, Model, Type } from "@typespec/compiler"; export type PlainDataDecorator = (context: DecoratorContext, target: Model) => void; + +export type HttpFileDecorator = (context: DecoratorContext, target: Model) => void; + +export type HttpPartDecorator = ( + context: DecoratorContext, + target: Model, + type: Type, + options: unknown +) => void; diff --git a/packages/http/generated-defs/TypeSpec.Http.ts b/packages/http/generated-defs/TypeSpec.Http.ts index 65edeef241..2e90bed137 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts @@ -116,6 +116,23 @@ export type BodyRootDecorator = (context: DecoratorContext, target: ModelPropert */ export type BodyIgnoreDecorator = (context: DecoratorContext, target: ModelProperty) => void; +/** + * + * + * + * @example + * ```tsp + * op upload( + * @header `content-type`: "multipart/form-data", + * @multipartBody body: { + * fullName: HttpPart, + * headShots: HttpPart[] + * } + * ): void; + * ``` + */ +export type MultipartBodyDecorator = (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 efd098a96a..fcdfdf9078 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts-test.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts-test.ts @@ -8,6 +8,7 @@ import { $head, $header, $includeInapplicableMetadataInPayload, + $multipartBody, $patch, $path, $post, @@ -28,6 +29,7 @@ import type { HeadDecorator, HeaderDecorator, IncludeInapplicableMetadataInPayloadDecorator, + MultipartBodyDecorator, PatchDecorator, PathDecorator, PostDecorator, @@ -48,6 +50,7 @@ type Decorators = { $path: PathDecorator; $bodyRoot: BodyRootDecorator; $bodyIgnore: BodyIgnoreDecorator; + $multipartBody: MultipartBodyDecorator; $get: GetDecorator; $put: PutDecorator; $post: PostDecorator; @@ -70,6 +73,7 @@ const _: Decorators = { $path, $bodyRoot, $bodyIgnore, + $multipartBody, $get, $put, $post, diff --git a/packages/http/lib/http-decorators.tsp b/packages/http/lib/decorators.tsp similarity index 96% rename from packages/http/lib/http-decorators.tsp rename to packages/http/lib/decorators.tsp index ed1b8fe879..0ed9455701 100644 --- a/packages/http/lib/http-decorators.tsp +++ b/packages/http/lib/decorators.tsp @@ -122,6 +122,21 @@ extern dec bodyRoot(target: ModelProperty); */ extern dec bodyIgnore(target: ModelProperty); +/** + * @example + * + * ```tsp + * op upload( + * @header `content-type`: "multipart/form-data", + * @multipartBody body: { + * fullName: HttpPart, + * headShots: HttpPart[] + * } + * ): void; + * ``` + */ +extern dec multipartBody(target: ModelProperty); + /** * Specify the status code for this response. Property type must be a status code integer or a union of status code integer. * @@ -296,10 +311,3 @@ extern dec route( * ``` */ extern dec sharedRoute(target: Operation); - -/** - * Private decorators. Those are meant for internal use inside Http types only. - */ -namespace Private { - extern dec plainData(target: TypeSpec.Reflection.Model); -} diff --git a/packages/http/lib/http.tsp b/packages/http/lib/http.tsp index 1b1d8d7557..411a7c1f3d 100644 --- a/packages/http/lib/http.tsp +++ b/packages/http/lib/http.tsp @@ -1,5 +1,6 @@ import "../dist/src/index.js"; -import "./http-decorators.tsp"; +import "./decorators.tsp"; +import "./private.decorators.tsp"; import "./auth.tsp"; namespace TypeSpec.Http; @@ -104,3 +105,18 @@ model ConflictResponse is Response<409>; model PlainData { ...Data; } + +@Private.httpFile +model File { + contentType?: string; + filename?: string; + contents: bytes; +} + +model HttpPartOptions { + /** Name of the part when using the array form. */ + name?: string; +} + +@Private.httpPart(Type, Options) +model HttpPart {} diff --git a/packages/http/lib/private.decorators.tsp b/packages/http/lib/private.decorators.tsp new file mode 100644 index 0000000000..87b4649870 --- /dev/null +++ b/packages/http/lib/private.decorators.tsp @@ -0,0 +1,14 @@ +import "../dist/src/private.decorators.js"; + +/** + * Private decorators. Those are meant for internal use inside Http types only. + */ +namespace TypeSpec.Http.Private; + +extern dec plainData(target: TypeSpec.Reflection.Model); +extern dec httpFile(target: TypeSpec.Reflection.Model); +extern dec httpPart( + target: TypeSpec.Reflection.Model, + type: unknown, + options: valueof HttpPartOptions +); diff --git a/packages/http/src/body.ts b/packages/http/src/body.ts deleted file mode 100644 index 462396bc4e..0000000000 --- a/packages/http/src/body.ts +++ /dev/null @@ -1,174 +0,0 @@ -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/content-types.ts b/packages/http/src/content-types.ts index eaf9dc99c6..f7a8751a7a 100644 --- a/packages/http/src/content-types.ts +++ b/packages/http/src/content-types.ts @@ -3,6 +3,7 @@ import { getHeaderFieldName } from "./decorators.js"; import { createDiagnostic } from "./lib.js"; /** + * @deprecated Use `OperationProperty.kind === 'contentType'` instead. * Check if the given model property is the content type header. * @param program Program * @param property Model property. @@ -39,6 +40,8 @@ export function getContentTypes(property: ModelProperty): [string[], readonly Di } return diagnostics.wrap(contentTypes); + } else if (property.type.kind === "Scalar" && property.type.name === "string") { + return [["*/*"], []]; } return [[], [createDiagnostic({ code: "content-type-string", target: property })]]; diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index 7325cd5531..a8473ed52b 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -17,12 +17,10 @@ import { ignoreDiagnostics, isArrayModelType, reportDeprecated, - setTypeSpecNamespace, typespecTypeToJson, validateDecoratorTarget, validateDecoratorUniqueOnNode, } from "@typespec/compiler"; -import { PlainDataDecorator } from "../generated-defs/TypeSpec.Http.Private.js"; import { BodyDecorator, BodyIgnoreDecorator, @@ -31,6 +29,7 @@ import { GetDecorator, HeadDecorator, HeaderDecorator, + MultipartBodyDecorator, PatchDecorator, PathDecorator, PostDecorator, @@ -221,6 +220,17 @@ export function isBodyIgnore(program: Program, entity: ModelProperty): boolean { return program.stateSet(HttpStateKeys.bodyIgnore).has(entity); } +export const $multipartBody: MultipartBodyDecorator = ( + context: DecoratorContext, + entity: ModelProperty +) => { + context.program.stateSet(HttpStateKeys.multipartBody).add(entity); +}; + +export function isMultipartBodyProperty(program: Program, entity: Type): boolean { + return program.stateSet(HttpStateKeys.multipartBody).has(entity); +} + export const $statusCode: StatusCodeDecorator = ( context: DecoratorContext, entity: ModelProperty @@ -450,36 +460,6 @@ export function getServers(program: Program, type: Namespace): HttpServer[] | un return program.stateMap(HttpStateKeys.servers).get(type); } -export const $plainData: PlainDataDecorator = (context: DecoratorContext, entity: Model) => { - const { program } = context; - - const decoratorsToRemove = ["$header", "$body", "$query", "$path", "$statusCode"]; - const [headers, bodies, queries, paths, statusCodes] = [ - program.stateMap(HttpStateKeys.header), - program.stateSet(HttpStateKeys.body), - program.stateMap(HttpStateKeys.query), - program.stateMap(HttpStateKeys.path), - program.stateMap(HttpStateKeys.statusCode), - ]; - - for (const property of entity.properties.values()) { - // Remove the decorators so that they do not run in the future, for example, - // if this model is later spread into another. - property.decorators = property.decorators.filter( - (d) => !decoratorsToRemove.includes(d.decorator.name) - ); - - // Remove the impact the decorators already had on this model. - headers.delete(property); - bodies.delete(property); - queries.delete(property); - paths.delete(property); - statusCodes.delete(property); - } -}; - -setTypeSpecNamespace("Private", $plainData); - export function $useAuth( context: DecoratorContext, entity: Namespace | Interface | Operation, diff --git a/packages/http/src/http-property.ts b/packages/http/src/http-property.ts new file mode 100644 index 0000000000..fffa6b4047 --- /dev/null +++ b/packages/http/src/http-property.ts @@ -0,0 +1,220 @@ +import { + DiagnosticResult, + Model, + Type, + compilerAssert, + createDiagnosticCollector, + walkPropertiesInherited, + type Diagnostic, + type ModelProperty, + type Program, +} from "@typespec/compiler"; +import { Queue } from "@typespec/compiler/utils"; +import { + getHeaderFieldOptions, + getPathParamOptions, + getQueryParamOptions, + isBody, + isBodyRoot, + isMultipartBodyProperty, + isStatusCode, +} from "./decorators.js"; +import { createDiagnostic } from "./lib.js"; +import { Visibility, isVisible } from "./metadata.js"; +import { HeaderFieldOptions, PathParameterOptions, QueryParameterOptions } from "./types.js"; + +export type HttpProperty = + | HeaderProperty + | ContentTypeProperty + | QueryProperty + | PathProperty + | StatusCodeProperty + | BodyProperty + | BodyRootProperty + | MultipartBodyProperty + | BodyPropertyProperty; + +export interface HttpPropertyBase { + readonly property: ModelProperty; +} + +export interface HeaderProperty extends HttpPropertyBase { + readonly kind: "header"; + readonly options: HeaderFieldOptions; +} + +export interface ContentTypeProperty extends HttpPropertyBase { + readonly kind: "contentType"; +} + +export interface QueryProperty extends HttpPropertyBase { + readonly kind: "query"; + readonly options: QueryParameterOptions; +} +export interface PathProperty extends HttpPropertyBase { + readonly kind: "path"; + readonly options: PathParameterOptions; +} +export interface StatusCodeProperty extends HttpPropertyBase { + readonly kind: "statusCode"; +} +export interface BodyProperty extends HttpPropertyBase { + readonly kind: "body"; +} +export interface BodyRootProperty extends HttpPropertyBase { + readonly kind: "bodyRoot"; +} +export interface MultipartBodyProperty extends HttpPropertyBase { + readonly kind: "multipartBody"; +} +/** Property to include inside the body */ +export interface BodyPropertyProperty extends HttpPropertyBase { + readonly kind: "bodyProperty"; +} + +export interface GetHttpPropertyOptions { + isImplicitPathParam?: (param: ModelProperty) => boolean; +} +/** + * Find the type of a property in a model + */ +export function getHttpProperty( + program: Program, + property: ModelProperty, + options: GetHttpPropertyOptions = {} +): [HttpProperty, readonly Diagnostic[]] { + const diagnostics: Diagnostic[] = []; + function createResult(opts: T): [T, readonly Diagnostic[]] { + return [{ ...opts, property } as any, diagnostics]; + } + + const annotations = { + header: getHeaderFieldOptions(program, property), + query: getQueryParamOptions(program, property), + path: getPathParamOptions(program, property), + body: isBody(program, property), + bodyRoot: isBodyRoot(program, property), + multipartBody: isMultipartBodyProperty(program, property), + statusCode: isStatusCode(program, property), + }; + const defined = Object.entries(annotations).filter((x) => !!x[1]); + if (defined.length === 0) { + if (options.isImplicitPathParam && options.isImplicitPathParam(property)) { + return createResult({ + kind: "path", + options: { + name: property.name, + type: "path", + }, + property, + }); + } + return [{ kind: "bodyProperty", property }, []]; + } else if (defined.length > 1) { + diagnostics.push( + createDiagnostic({ + code: "operation-param-duplicate-type", + format: { paramName: property.name, types: defined.map((x) => x[0]).join(", ") }, + target: property, + }) + ); + } + + if (annotations.header) { + if (annotations.header.name.toLowerCase() === "content-type") { + return createResult({ kind: "contentType", property }); + } else { + return createResult({ kind: "header", options: annotations.header, property }); + } + } else if (annotations.query) { + return createResult({ kind: "query", options: annotations.query, property }); + } else if (annotations.path) { + return createResult({ kind: "path", options: annotations.path, property }); + } else if (annotations.statusCode) { + return createResult({ kind: "statusCode", property }); + } else if (annotations.body) { + return createResult({ kind: "body", property }); + } else if (annotations.bodyRoot) { + return createResult({ kind: "bodyRoot", property }); + } else if (annotations.multipartBody) { + return createResult({ kind: "multipartBody", property }); + } + compilerAssert(false, `Unexpected http property type`, property); +} + +/** + * Walks the given input(request parameters or response) and return all the properties and where they should be included(header, query, path, body, as a body property, etc.) + * + * @param rootMapOut If provided, the map will be populated to link nested metadata properties to their root properties. + */ +export function resolvePayloadProperties( + program: Program, + type: Type, + visibility: Visibility, + options: GetHttpPropertyOptions = {} +): DiagnosticResult { + const diagnostics = createDiagnosticCollector(); + const httpProperties = new Map(); + + if (type.kind !== "Model" || type.properties.size === 0) { + return diagnostics.wrap([]); + } + + const visited = new Set(); + const queue = new Queue<[Model, ModelProperty | undefined]>([[type, undefined]]); + + while (!queue.isEmpty()) { + const [model, rootOpt] = queue.dequeue(); + visited.add(model); + + for (const property of walkPropertiesInherited(model)) { + const root = rootOpt ?? property; + + if (!isVisible(program, property, visibility)) { + continue; + } + + let httpProperty = diagnostics.pipe(getHttpProperty(program, property, options)); + if (shouldTreatAsBodyProperty(httpProperty, visibility)) { + httpProperty = { kind: "bodyProperty", property }; + } + httpProperties.set(property, httpProperty); + if ( + property !== root && + (httpProperty.kind === "body" || + httpProperty.kind === "bodyRoot" || + httpProperty.kind === "multipartBody") + ) { + const parent = httpProperties.get(root); + if (parent?.kind === "bodyProperty") { + httpProperties.delete(root); + } + } + if (httpProperty.kind === "body" || httpProperty.kind === "multipartBody") { + continue; // We ignore any properties under `@body` or `@multipartBody` + } + + if ( + property.type.kind === "Model" && + !type.indexer && + type.properties.size > 0 && + !visited.has(property.type) + ) { + queue.enqueue([property.type, root]); + } + } + } + + return diagnostics.wrap([...httpProperties.values()]); +} + +function shouldTreatAsBodyProperty(property: HttpProperty, visibility: Visibility): boolean { + if (visibility & Visibility.Read) { + return property.kind === "query" || property.kind === "path"; + } + + if (!(visibility & Visibility.Read)) { + return property.kind === "statusCode"; + } + return false; +} diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index c3d8bcd881..ee792cda39 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -7,6 +7,7 @@ export * from "./decorators.js"; export * from "./metadata.js"; export * from "./operations.js"; export * from "./parameters.js"; +export { getHttpFileModel, isHttpFile, isOrExtendsHttpFile } from "./private.decorators.js"; export * from "./responses.js"; export * from "./route.js"; export * from "./types.js"; diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index 8d8ed02b7a..2d367444b1 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -117,12 +117,36 @@ export const $lib = createTypeSpecLibrary({ default: `@visibility("write") is not supported. Use @visibility("update"), @visibility("create") or @visibility("create", "update") as appropriate.`, }, }, + "multipart-invalid-content-type": { + severity: "error", + messages: { + default: paramMessage`Content type '${"contentType"}' is not a multipart content type. Supported content types are: ${"supportedContentTypes"}.`, + }, + }, "multipart-model": { severity: "error", messages: { default: "Multipart request body must be a model.", }, }, + "multipart-part": { + severity: "error", + messages: { + default: "Expect item to be an HttpPart model.", + }, + }, + "multipart-nested": { + severity: "error", + messages: { + default: "Cannot use @multipartBody inside of an HttpPart", + }, + }, + "formdata-no-part-name": { + severity: "error", + messages: { + default: "Part used in multipart/form-data must have a name.", + }, + }, "header-format-required": { severity: "error", messages: { @@ -144,6 +168,7 @@ export const $lib = createTypeSpecLibrary({ body: { description: "State for the @body decorator" }, bodyRoot: { description: "State for the @bodyRoot decorator" }, bodyIgnore: { description: "State for the @bodyIgnore decorator" }, + multipartBody: { 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" }, @@ -157,6 +182,10 @@ export const $lib = createTypeSpecLibrary({ routes: {}, sharedRoutes: { description: "State for the @sharedRoute decorator" }, routeOptions: {}, + + // private + file: { description: "State for the @Private.file decorator" }, + httpPart: { description: "State for the @Private.httpPart decorator" }, }, } as const); diff --git a/packages/http/src/metadata.ts b/packages/http/src/metadata.ts index 5e2c0ad4c0..8bf409c3a6 100644 --- a/packages/http/src/metadata.ts +++ b/packages/http/src/metadata.ts @@ -1,6 +1,5 @@ import { compilerAssert, - DiagnosticCollector, getEffectiveModelType, getParameterVisibility, isVisible as isVisibleCore, @@ -8,18 +7,17 @@ import { ModelProperty, Operation, Program, - Queue, - TwoLevelMap, Type, Union, - walkPropertiesInherited, } from "@typespec/compiler"; +import { TwoLevelMap } from "@typespec/compiler/utils"; import { includeInapplicableMetadataInPayload, isBody, isBodyIgnore, isBodyRoot, isHeader, + isMultipartBodyProperty, isPathParam, isQueryParam, isStatusCode, @@ -219,72 +217,6 @@ export function resolveRequestVisibility( return visibility; } -/** - * Walks the given type and collects all applicable metadata and `@body` - * properties recursively. - * - * @param rootMapOut If provided, the map will be populated to link - * nested metadata properties to their root properties. - */ -export function gatherMetadata( - program: Program, - diagnostics: DiagnosticCollector, // currently unused, but reserved for future diagnostics - type: Type, - visibility: Visibility, - isMetadataCallback = isMetadata, - rootMapOut?: Map -): Set { - const metadata = new Map(); - if (type.kind !== "Model" || type.properties.size === 0) { - return new Set(); - } - - const visited = new Set(); - const queue = new Queue<[Model, ModelProperty | undefined]>([[type, undefined]]); - - while (!queue.isEmpty()) { - const [model, rootOpt] = queue.dequeue(); - visited.add(model); - - for (const property of walkPropertiesInherited(model)) { - const root = rootOpt ?? property; - - if (!isVisible(program, property, visibility)) { - continue; - } - - // ISSUE: This should probably be an error, but that's a breaking - // change that currently breaks some samples and tests. - // - // The traversal here is level-order so that the preferred metadata in - // the case of duplicates, which is the most compatible with prior - // behavior where nested metadata was always dropped. - if (metadata.has(property.name)) { - continue; - } - - 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 ( - property.type.kind === "Model" && - !type.indexer && - type.properties.size > 0 && - !visited.has(property.type) - ) { - queue.enqueue([property.type, root]); - } - } - } - - return new Set(metadata.values()); -} - /** * Determines if a property is metadata. A property is defined to be * metadata if it is marked `@header`, `@query`, `@path`, or `@statusCode`. @@ -348,7 +280,12 @@ function isApplicableMetadataCore( return false; // no metadata is applicable to collection items } - if (treatBodyAsMetadata && (isBody(program, property) || isBodyRoot(program, property))) { + if ( + treatBodyAsMetadata && + (isBody(program, property) || + isBodyRoot(program, property) || + isMultipartBodyProperty(program, property)) + ) { return true; } @@ -602,7 +539,7 @@ export function createMetadataInfo(program: Program, options?: MetadataInfoOptio /** * If the type is an anonymous model, tries to find a named model that has the same - * set of properties when non-payload properties are excluded. + * set of properties when non-payload properties are excluded.we */ function getEffectivePayloadType(type: Type, visibility: Visibility): Type { if (type.kind === "Model" && !type.name) { diff --git a/packages/http/src/parameters.ts b/packages/http/src/parameters.ts index 1b26aa9ace..9486c10193 100644 --- a/packages/http/src/parameters.ts +++ b/packages/http/src/parameters.ts @@ -5,23 +5,14 @@ import { Operation, Program, } from "@typespec/compiler"; -import { resolveBody, ResolvedBody } from "./body.js"; -import { getContentTypes, isContentTypeHeader } from "./content-types.js"; -import { - getHeaderFieldOptions, - getOperationVerb, - getPathParamOptions, - getQueryParamOptions, - isBody, - isBodyRoot, -} from "./decorators.js"; +import { getOperationVerb } from "./decorators.js"; import { createDiagnostic } from "./lib.js"; -import { gatherMetadata, isMetadata, resolveRequestVisibility } from "./metadata.js"; +import { resolveRequestVisibility } from "./metadata.js"; +import { resolveHttpPayload } from "./payload.js"; import { HttpOperation, HttpOperationParameter, HttpOperationParameters, - HttpOperationRequestBody, HttpVerb, OperationParameterOptions, } from "./types.js"; @@ -60,82 +51,49 @@ function getOperationParametersForVerb( ): [HttpOperationParameters, readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); const visibility = resolveRequestVisibility(program, operation, verb); - const rootPropertyMap = new Map(); - const metadata = gatherMetadata( - program, - diagnostics, - operation.parameters, - visibility, - (_, param) => isMetadata(program, param) || isImplicitPathParam(param), - rootPropertyMap - ); - function isImplicitPathParam(param: ModelProperty) { const isTopLevel = param.model === operation.parameters; return isTopLevel && knownPathParamNames.includes(param.name); } const parameters: HttpOperationParameter[] = []; - const resolvedBody = diagnostics.pipe( - resolveBody(program, operation.parameters, metadata, rootPropertyMap, visibility, "request") + const { body: resolvedBody, metadata } = diagnostics.pipe( + resolveHttpPayload(program, operation.parameters, visibility, "request", { + isImplicitPathParam, + }) ); - let contentTypes: string[] | undefined; - for (const param of metadata) { - const queryOptions = getQueryParamOptions(program, param); - const pathOptions = - getPathParamOptions(program, param) ?? - (isImplicitPathParam(param) && { type: "path", name: param.name }); - const headerOptions = getHeaderFieldOptions(program, param); - const isBodyVal = isBody(program, param); - const isBodyRootVal = isBodyRoot(program, param); - const defined = [ - ["query", queryOptions], - ["path", pathOptions], - ["header", headerOptions], - ["body", isBodyVal || isBodyRootVal], - ].filter((x) => !!x[1]); - if (defined.length >= 2) { - diagnostics.add( - createDiagnostic({ - code: "operation-param-duplicate-type", - format: { paramName: param.name, types: defined.map((x) => x[0]).join(", ") }, - target: param, - }) - ); - } - - if (queryOptions) { - parameters.push({ - ...queryOptions, - param, - }); - } else if (pathOptions) { - if (param.optional) { - diagnostics.add( - createDiagnostic({ - code: "optional-path-param", - format: { paramName: param.name }, - target: operation, - }) - ); - } - parameters.push({ - ...pathOptions, - param, - }); - } else if (headerOptions) { - if (isContentTypeHeader(program, param)) { - contentTypes = diagnostics.pipe(getContentTypes(param)); - } - parameters.push({ - ...headerOptions, - param, - }); + for (const item of metadata) { + switch (item.kind) { + case "contentType": + parameters.push({ + name: "Content-Type", + type: "header", + param: item.property, + }); + break; + case "path": + if (item.property.optional) { + diagnostics.add( + createDiagnostic({ + code: "optional-path-param", + format: { paramName: item.property.name }, + target: item.property, + }) + ); + } + // eslint-disable-next-line no-fallthrough + case "query": + case "header": + parameters.push({ + ...item.options, + param: item.property, + }); + break; } } - const body = diagnostics.pipe(computeHttpOperationBody(operation, resolvedBody, contentTypes)); + const body = resolvedBody; return diagnostics.wrap({ parameters, @@ -145,48 +103,7 @@ function getOperationParametersForVerb( return body?.type; }, get bodyParameter() { - return body?.parameter; + return body?.property; }, }); } - -function computeHttpOperationBody( - operation: Operation, - resolvedBody: ResolvedBody | undefined, - contentTypes: string[] | undefined -): [HttpOperationRequestBody | undefined, readonly Diagnostic[]] { - contentTypes ??= []; - const diagnostics: Diagnostic[] = []; - if (resolvedBody === undefined) { - if (contentTypes.length > 0) { - diagnostics.push( - createDiagnostic({ - code: "content-type-ignored", - target: operation.parameters, - }) - ); - } - return [undefined, diagnostics]; - } - - if (contentTypes.includes("multipart/form-data") && resolvedBody.type.kind !== "Model") { - diagnostics.push( - createDiagnostic({ - code: "multipart-model", - target: resolvedBody.property ?? operation.parameters, - }) - ); - return [undefined, diagnostics]; - } - - const body: HttpOperationRequestBody = { - 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/payload.ts b/packages/http/src/payload.ts new file mode 100644 index 0000000000..ffa19ae35d --- /dev/null +++ b/packages/http/src/payload.ts @@ -0,0 +1,444 @@ +import { + Diagnostic, + Model, + ModelProperty, + Program, + Tuple, + Type, + createDiagnosticCollector, + filterModelProperties, + getDiscriminator, + getEncode, + ignoreDiagnostics, + isArrayModelType, + navigateType, +} from "@typespec/compiler"; +import { DuplicateTracker } from "@typespec/compiler/utils"; +import { getContentTypes } from "./content-types.js"; +import { isHeader, isPathParam, isQueryParam, isStatusCode } from "./decorators.js"; +import { + GetHttpPropertyOptions, + HeaderProperty, + HttpProperty, + resolvePayloadProperties, +} from "./http-property.js"; +import { createDiagnostic } from "./lib.js"; +import { Visibility } from "./metadata.js"; +import { getHttpFileModel, getHttpPart } from "./private.decorators.js"; +import { HttpOperationBody, HttpOperationMultipartBody, HttpOperationPart } from "./types.js"; + +export interface HttpPayload { + readonly body?: HttpOperationBody | HttpOperationMultipartBody; + readonly metadata: HttpProperty[]; +} +export interface ExtractBodyAndMetadataOptions extends GetHttpPropertyOptions {} +export function resolveHttpPayload( + program: Program, + type: Type, + visibility: Visibility, + usedIn: "request" | "response" | "multipart", + options: ExtractBodyAndMetadataOptions = {} +): [HttpPayload, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + + const metadata = diagnostics.pipe(resolvePayloadProperties(program, type, visibility, options)); + + const body = diagnostics.pipe(resolveBody(program, type, metadata, visibility, usedIn)); + + if (body) { + if ( + body.contentTypes.includes("multipart/form-data") && + body.bodyKind === "single" && + body.type.kind !== "Model" + ) { + diagnostics.add( + createDiagnostic({ + code: "multipart-model", + target: body.property ?? type, + }) + ); + return diagnostics.wrap({ body: undefined, metadata }); + } + } + + return diagnostics.wrap({ body, metadata }); +} + +function resolveBody( + program: Program, + requestOrResponseType: Type, + metadata: HttpProperty[], + visibility: Visibility, + usedIn: "request" | "response" | "multipart" +): [HttpOperationBody | HttpOperationMultipartBody | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const { contentTypes, contentTypeProperty } = diagnostics.pipe( + resolveContentTypes(program, metadata, usedIn) + ); + + const file = getHttpFileModel(program, requestOrResponseType); + if (file !== undefined) { + const file = getHttpFileModel(program, requestOrResponseType)!; + return diagnostics.wrap({ + bodyKind: "single", + contentTypes: diagnostics.pipe(getContentTypes(file.contentType)), + type: file.contents.type, + isExplicit: false, + containsMetadataAnnotations: false, + }); + } + + // non-model or intrinsic/array model -> response body is response type + if (requestOrResponseType.kind !== "Model" || isArrayModelType(program, requestOrResponseType)) { + return diagnostics.wrap({ + bodyKind: "single", + contentTypes, + type: requestOrResponseType, + isExplicit: false, + containsMetadataAnnotations: false, + }); + } + + // look for explicit body + const resolvedBody: HttpOperationBody | HttpOperationMultipartBody | undefined = diagnostics.pipe( + resolveExplicitBodyProperty(program, metadata, contentTypes, visibility, usedIn) + ); + + 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({ + bodyKind: "single", + contentTypes, + 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({ + bodyKind: "single", + contentTypes, + type: requestOrResponseType, + isExplicit: false, + containsMetadataAnnotations: false, + }); + } + } + + const unannotatedProperties = filterModelProperties(program, requestOrResponseType, (p) => + metadata.some((x) => x.property === p && x.kind === "bodyProperty") + ); + + if (unannotatedProperties.properties.size > 0) { + if (resolvedBody === undefined) { + return diagnostics.wrap({ + bodyKind: "single", + contentTypes, + type: unannotatedProperties, + isExplicit: false, + containsMetadataAnnotations: false, + }); + } else { + diagnostics.add( + createDiagnostic({ + code: "duplicate-body", + messageId: "bodyAndUnannotated", + target: requestOrResponseType, + }) + ); + } + } + if (resolvedBody === undefined && contentTypeProperty) { + diagnostics.add( + createDiagnostic({ + code: "content-type-ignored", + target: contentTypeProperty, + }) + ); + } + return diagnostics.wrap(resolvedBody); +} + +function resolveContentTypes( + program: Program, + metadata: HttpProperty[], + usedIn: "request" | "response" | "multipart" +): [{ contentTypes: string[]; contentTypeProperty?: ModelProperty }, readonly Diagnostic[]] { + for (const prop of metadata) { + if (prop.kind === "contentType") { + const [contentTypes, diagnostics] = getContentTypes(prop.property); + return [{ contentTypes, contentTypeProperty: prop.property }, diagnostics]; + } + } + switch (usedIn) { + case "multipart": + // Figure this out later + return [{ contentTypes: [] }, []]; + default: + return [{ contentTypes: ["application/json"] }, []]; + } +} + +function resolveExplicitBodyProperty( + program: Program, + metadata: HttpProperty[], + contentTypes: string[], + visibility: Visibility, + usedIn: "request" | "response" | "multipart" +): [HttpOperationBody | HttpOperationMultipartBody | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + let resolvedBody: HttpOperationBody | HttpOperationMultipartBody | undefined; + const duplicateTracker = new DuplicateTracker(); + + for (const item of metadata) { + if (item.kind === "body" || item.kind === "bodyRoot" || item.kind === "multipartBody") { + duplicateTracker.track("body", item.property); + } + + switch (item.kind) { + case "body": + case "bodyRoot": + let containsMetadataAnnotations = false; + if (item.kind === "body") { + const valid = diagnostics.pipe(validateBodyProperty(program, item.property, usedIn)); + containsMetadataAnnotations = !valid; + } + if (resolvedBody === undefined) { + resolvedBody = { + bodyKind: "single", + contentTypes, + type: item.property.type, + isExplicit: item.kind === "body", + containsMetadataAnnotations, + property: item.property, + parameter: item.property, + }; + } + break; + case "multipartBody": + resolvedBody = diagnostics.pipe( + resolveMultiPartBody(program, item.property, contentTypes, visibility) + ); + break; + } + } + for (const [_, items] of duplicateTracker.entries()) { + for (const prop of items) { + diagnostics.add( + createDiagnostic({ + code: "duplicate-body", + target: prop, + }) + ); + } + } + + return diagnostics.wrap(resolvedBody); +} + +/** Validate a property marked with `@body` */ +function validateBodyProperty( + program: Program, + property: ModelProperty, + usedIn: "request" | "response" | "multipart" +): [boolean, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + navigateType( + property.type, + { + modelProperty: (prop) => { + const kind = isHeader(program, prop) + ? "header" + : (usedIn === "request" || usedIn === "multipart") && 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); +} + +function resolveMultiPartBody( + program: Program, + property: ModelProperty, + contentTypes: string[], + visibility: Visibility +): [HttpOperationMultipartBody | undefined, readonly Diagnostic[]] { + const type = property.type; + if (type.kind === "Model") { + return resolveMultiPartBodyFromModel(program, property, type, contentTypes, visibility); + } else if (type.kind === "Tuple") { + return resolveMultiPartBodyFromTuple(program, property, type, contentTypes, visibility); + } else { + return [undefined, [createDiagnostic({ code: "multipart-model", target: property })]]; + } +} + +function resolveMultiPartBodyFromModel( + program: Program, + property: ModelProperty, + type: Model, + contentTypes: string[], + visibility: Visibility +): [HttpOperationMultipartBody | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const parts: HttpOperationPart[] = []; + for (const item of type.properties.values()) { + const part = diagnostics.pipe(resolvePartOrParts(program, item.type, visibility)); + if (part) { + parts.push({ ...part, name: item.name, optional: item.optional }); + } + } + + return diagnostics.wrap({ bodyKind: "multipart", contentTypes, parts, property, type }); +} + +const multipartContentTypes = { + formData: "multipart/form-data", + mixed: "multipart/mixed", +} as const; +const multipartContentTypesValues = Object.values(multipartContentTypes); + +function resolveMultiPartBodyFromTuple( + program: Program, + property: ModelProperty, + type: Tuple, + contentTypes: string[], + visibility: Visibility +): [HttpOperationMultipartBody | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const parts: HttpOperationPart[] = []; + + for (const contentType of contentTypes) { + if (!multipartContentTypesValues.includes(contentType as any)) { + diagnostics.add( + createDiagnostic({ + code: "multipart-invalid-content-type", + format: { contentType, valid: multipartContentTypesValues.join(", ") }, + target: type, + }) + ); + } + } + for (const [index, item] of type.values.entries()) { + const part = diagnostics.pipe(resolvePartOrParts(program, item, visibility)); + if (part?.name === undefined && contentTypes.includes(multipartContentTypes.formData)) { + diagnostics.add( + createDiagnostic({ + code: "formdata-no-part-name", + target: type.node.values[index], + }) + ); + } + if (part) { + parts.push(part); + } + } + + return diagnostics.wrap({ bodyKind: "multipart", contentTypes, parts, property, type }); +} + +function resolvePartOrParts( + program: Program, + type: Type, + visibility: Visibility +): [HttpOperationPart | undefined, readonly Diagnostic[]] { + if (type.kind === "Model" && isArrayModelType(program, type)) { + const [part, diagnostics] = resolvePart(program, type.indexer.value, visibility); + if (part) { + return [{ ...part, multi: true }, diagnostics]; + } + return [part, diagnostics]; + } else { + return resolvePart(program, type, visibility); + } +} + +function resolvePart( + program: Program, + type: Type, + visibility: Visibility +): [HttpOperationPart | undefined, readonly Diagnostic[]] { + const part = getHttpPart(program, type); + if (part) { + let [{ body, metadata }, diagnostics] = resolveHttpPayload( + program, + part.type, + visibility, + "multipart" + ); + if (body === undefined) { + return [undefined, diagnostics]; + } else if (body.bodyKind === "multipart") { + return [undefined, [createDiagnostic({ code: "multipart-nested", target: type })]]; + } + + if (body.contentTypes.length === 0) { + body = { ...body, contentTypes: resolveDefaultContentTypeForPart(program, body.type) }; + } + return [ + { + multi: false, + name: part.options.name, + body, + optional: false, + headers: metadata.filter((x): x is HeaderProperty => x.kind === "header"), + }, + diagnostics, + ]; + } + return [undefined, [createDiagnostic({ code: "multipart-part", target: type })]]; +} + +function resolveDefaultContentTypeForPart(program: Program, type: Type): string[] { + function resolve(type: Type): string[] { + if (type.kind === "Scalar") { + const encodedAs = getEncode(program, type); + if (encodedAs) { + type = encodedAs.type; + } + + if ( + ignoreDiagnostics( + program.checker.isTypeAssignableTo( + type.projectionBase ?? type, + program.checker.getStdType("bytes"), + type + ) + ) + ) { + return ["application/octet-stream"]; + } else { + return ["text/plain"]; + } + } else if (type.kind === "Union") { + return [...type.variants.values()].flatMap((x) => resolve(x.type)); + } else { + return ["application/json"]; + } + } + + return [...new Set(resolve(type))]; +} diff --git a/packages/http/src/private.decorators.ts b/packages/http/src/private.decorators.ts new file mode 100644 index 0000000000..d6a22c63e8 --- /dev/null +++ b/packages/http/src/private.decorators.ts @@ -0,0 +1,115 @@ +import { + DecoratorContext, + Model, + ModelProperty, + Program, + Type, + getProperty, +} from "@typespec/compiler"; +import { + HttpFileDecorator, + HttpPartDecorator, + PlainDataDecorator, +} from "../generated-defs/TypeSpec.Http.Private.js"; +import { HttpStateKeys } from "./lib.js"; + +export const namespace = "TypeSpec.Http.Private"; + +export const $plainData: PlainDataDecorator = (context: DecoratorContext, entity: Model) => { + const { program } = context; + + const decoratorsToRemove = ["$header", "$body", "$query", "$path", "$statusCode"]; + const [headers, bodies, queries, paths, statusCodes] = [ + program.stateMap(HttpStateKeys.header), + program.stateSet(HttpStateKeys.body), + program.stateMap(HttpStateKeys.query), + program.stateMap(HttpStateKeys.path), + program.stateMap(HttpStateKeys.statusCode), + ]; + + for (const property of entity.properties.values()) { + // Remove the decorators so that they do not run in the future, for example, + // if this model is later spread into another. + property.decorators = property.decorators.filter( + (d) => !decoratorsToRemove.includes(d.decorator.name) + ); + + // Remove the impact the decorators already had on this model. + headers.delete(property); + bodies.delete(property); + queries.delete(property); + paths.delete(property); + statusCodes.delete(property); + } +}; + +export const $httpFile: HttpFileDecorator = (context: DecoratorContext, target: Model) => { + context.program.stateSet(HttpStateKeys.file).add(target); +}; + +/** + * Check if the given type is an `HttpFile` + */ +export function isHttpFile(program: Program, type: Type) { + return program.stateSet(HttpStateKeys.file).has(type); +} + +export function isOrExtendsHttpFile(program: Program, type: Type) { + if (type.kind !== "Model") { + return false; + } + + let current: Model | undefined = type; + + while (current) { + if (isHttpFile(program, current)) { + return true; + } + + current = current.baseModel; + } + + return false; +} + +export interface HttpFileModel { + readonly type: Type; + readonly contentType: ModelProperty; + readonly filename: ModelProperty; + readonly contents: ModelProperty; +} + +export function getHttpFileModel(program: Program, type: Type): HttpFileModel | undefined { + if (type.kind !== "Model" || !isOrExtendsHttpFile(program, type)) { + return undefined; + } + + const contentType = getProperty(type, "contentType")!; + const filename = getProperty(type, "filename")!; + const contents = getProperty(type, "contents")!; + + return { contents, contentType, filename, type }; +} + +export interface HttpPartOptions { + readonly name?: string; +} + +export const $httpPart: HttpPartDecorator = ( + context: DecoratorContext, + target: Model, + type, + options +) => { + context.program.stateMap(HttpStateKeys.file).set(target, { type, options }); +}; + +export interface HttpPart { + readonly type: Type; + readonly options: HttpPartOptions; +} + +/** Return the http part information on a model that is an `HttpPart` */ +export function getHttpPart(program: Program, target: Type): HttpPart | undefined { + return program.stateMap(HttpStateKeys.file).get(target); +} diff --git a/packages/http/src/responses.ts b/packages/http/src/responses.ts index 37d16402c8..6e2d952d55 100644 --- a/packages/http/src/responses.ts +++ b/packages/http/src/responses.ts @@ -14,18 +14,18 @@ import { Program, Type, } from "@typespec/compiler"; -import { resolveBody, ResolvedBody } from "./body.js"; -import { getContentTypes, isContentTypeHeader } from "./content-types.js"; +import { getStatusCodeDescription, getStatusCodesWithDiagnostics } from "./decorators.js"; +import { HttpProperty } from "./http-property.js"; +import { HttpStateKeys, reportDiagnostic } from "./lib.js"; +import { Visibility } from "./metadata.js"; +import { resolveHttpPayload } from "./payload.js"; import { - getHeaderFieldName, - getStatusCodeDescription, - getStatusCodesWithDiagnostics, - isHeader, - isStatusCode, -} from "./decorators.js"; -import { createDiagnostic, HttpStateKeys, reportDiagnostic } from "./lib.js"; -import { gatherMetadata, Visibility } from "./metadata.js"; -import { HttpOperationResponse, HttpStatusCodes, HttpStatusCodesEntry } from "./types.js"; + HttpOperationBody, + HttpOperationMultipartBody, + HttpOperationResponse, + HttpStatusCodes, + HttpStatusCodesEntry, +} from "./types.js"; /** * Get the responses for a given operation. @@ -86,32 +86,18 @@ function processResponseType( responses: ResponseIndex, responseType: Type ) { - const rootPropertyMap = new Map(); - const metadata = gatherMetadata( - program, - diagnostics, - responseType, - Visibility.Read, - undefined, - rootPropertyMap + // Get body + let { body: resolvedBody, metadata } = diagnostics.pipe( + resolveHttpPayload(program, responseType, Visibility.Read, "response") ); - // Get explicity defined status codes const statusCodes: HttpStatusCodes = diagnostics.pipe( getResponseStatusCodes(program, responseType, metadata) ); - // Get explicitly defined content types - const contentTypes = getResponseContentTypes(program, diagnostics, metadata); - // Get response headers const headers = getResponseHeaders(program, metadata); - // Get body - 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 (isErrorModel(program, responseType)) { @@ -127,11 +113,6 @@ function processResponseType( } } - // If there is a body but no explicit content types, use application/json - if (resolvedBody && contentTypes.length === 0) { - contentTypes.push("application/json"); - } - // Put them into currentEndpoint.responses for (const statusCode of statusCodes) { // the first model for this statusCode/content type pair carries the @@ -152,19 +133,9 @@ function processResponseType( if (resolvedBody !== undefined) { response.responses.push({ - body: { - contentTypes: contentTypes, - ...resolvedBody, - }, + body: resolvedBody, headers, }); - } else if (contentTypes.length > 0) { - diagnostics.add( - createDiagnostic({ - code: "content-type-ignored", - target: responseType, - }) - ); } else { response.responses.push({ headers }); } @@ -180,14 +151,14 @@ function processResponseType( function getResponseStatusCodes( program: Program, responseType: Type, - metadata: Set + metadata: HttpProperty[] ): [HttpStatusCodes, readonly Diagnostic[]] { const codes: HttpStatusCodes = []; const diagnostics = createDiagnosticCollector(); let statusFound = false; for (const prop of metadata) { - if (isStatusCode(program, prop)) { + if (prop.kind === "statusCode") { if (statusFound) { reportDiagnostic(program, { code: "multiple-status-codes", @@ -195,7 +166,7 @@ function getResponseStatusCodes( }); } statusFound = true; - codes.push(...diagnostics.pipe(getStatusCodesWithDiagnostics(program, prop))); + codes.push(...diagnostics.pipe(getStatusCodesWithDiagnostics(program, prop.property))); } } @@ -214,37 +185,17 @@ function getExplicitSetStatusCode(program: Program, entity: Model | ModelPropert return program.stateMap(HttpStateKeys.statusCode).get(entity) ?? []; } -/** - * Get explicity defined content-types from response metadata - * Return is an array of strings, possibly empty, which indicates no explicitly defined content-type. - * We do not check for duplicates here -- that will be done by the caller. - */ -function getResponseContentTypes( - program: Program, - diagnostics: DiagnosticCollector, - metadata: Set -): string[] { - const contentTypes: string[] = []; - for (const prop of metadata) { - if (isHeader(program, prop) && isContentTypeHeader(program, prop)) { - contentTypes.push(...diagnostics.pipe(getContentTypes(prop))); - } - } - return contentTypes; -} - /** * Get response headers from response metadata */ function getResponseHeaders( program: Program, - metadata: Set + metadata: HttpProperty[] ): Record { const responseHeaders: Record = {}; for (const prop of metadata) { - const headerName = getHeaderFieldName(program, prop); - if (isHeader(program, prop) && headerName !== "content-type") { - responseHeaders[headerName] = prop; + if (prop.kind === "header") { + responseHeaders[prop.options.name] = prop.property; } } return responseHeaders; @@ -255,7 +206,7 @@ function getResponseDescription( operation: Operation, responseType: Type, statusCode: HttpStatusCodes[number], - body: ResolvedBody | undefined + body: HttpOperationBody | HttpOperationMultipartBody | 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 diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index e69de12398..3c9e2bb7b3 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -2,12 +2,15 @@ import { DiagnosticResult, Interface, ListOperationOptions, + Model, ModelProperty, Namespace, Operation, Program, + Tuple, Type, } from "@typespec/compiler"; +import { HeaderProperty } from "./http-property.js"; /** * @deprecated use `HttpOperation`. To remove in November 2022 release. @@ -317,28 +320,18 @@ export type HttpOperationParameter = ( }; /** - * Represent the body information for an http request. - * - * @note the `type` must be a `Model` if the content type is multipart. + * @deprecated use {@link HttpOperationBody} */ -export interface HttpOperationRequestBody extends HttpOperationBody { - /** - * 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 type HttpOperationRequestBody = HttpOperationBody; +/** + * @deprecated use {@link HttpOperationBody} + */ +export type HttpOperationResponseBody = HttpOperationBody; export interface HttpOperationParameters { parameters: HttpOperationParameter[]; - body?: HttpOperationRequestBody; + body?: HttpOperationBody | HttpOperationMultipartBody; /** @deprecated use {@link body.type} */ bodyType?: Type; @@ -446,25 +439,59 @@ export interface HttpOperationResponse { export interface HttpOperationResponseContent { headers?: Record; - body?: HttpOperationResponseBody; + body?: HttpOperationBody | HttpOperationMultipartBody; } -export interface HttpOperationBody { - /** - * Content types. - */ - contentTypes: string[]; +export interface HttpOperationBodyBase { + /** Content types. */ + readonly contentTypes: string[]; +} - /** - * Type of the operation body. - */ - type: Type; +export interface HttpBody { + readonly 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; + + /** + * @deprecated use {@link property} + */ + parameter?: ModelProperty; + + /** + * If the body was explicitly set as a property. Correspond to the property with `@body` or `@bodyRoot` + */ + readonly property?: ModelProperty; +} + +export interface HttpOperationBody extends HttpOperationBodyBase, HttpBody { + readonly bodyKind: "single"; +} + +/** Body marked with `@multipartBody` */ +export interface HttpOperationMultipartBody extends HttpOperationBodyBase { + readonly bodyKind: "multipart"; + readonly type: Model | Tuple; + /** Property annotated with `@multipartBody` */ + readonly property: ModelProperty; + readonly parts: HttpOperationPart[]; +} + +/** Represent an part in a multipart body. */ +export interface HttpOperationPart { + /** Part name */ + readonly name?: string; + /** If the part is optional */ + readonly optional: boolean; + /** Part body */ + readonly body: HttpOperationBody; + /** Part headers */ + readonly headers: HeaderProperty[]; + /** If there can be multiple of that part */ + readonly multi: boolean; } export interface HttpStatusCodeRange { diff --git a/packages/http/test/multipart.test.ts b/packages/http/test/multipart.test.ts new file mode 100644 index 0000000000..1653a90355 --- /dev/null +++ b/packages/http/test/multipart.test.ts @@ -0,0 +1,199 @@ +import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing"; +import { deepStrictEqual, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { HttpOperationMultipartBody } from "../src/types.js"; +import { getOperationsWithServiceNamespace } from "./test-host.js"; + +it("emit diagnostic when using invalid content type for multipart ", async () => { + const diagnostics = await diagnoseHttpOp(` + op read( + @header contentType: "application/json", + @multipartBody body: [HttpPart]): void; + `); + expectDiagnostics(diagnostics, { + code: "@typespec/http/multipart-invalid-content-type", + message: + "Content type 'application/json' is not a multipart content type. Supported content types are: .", + }); +}); + +describe("define with the tuple form", () => { + describe("part without name", () => { + it("emit diagnostic when using multipart/form-data", async () => { + const diagnostics = await diagnoseHttpOp(` + op read( + @header contentType: "multipart/form-data", + @multipartBody body: [HttpPart]): void; + `); + expectDiagnostics(diagnostics, { + code: "@typespec/http/formdata-no-part-name", + message: "Part used in multipart/form-data must have a name.", + }); + }); + + it("include anonymous part when multipart/form-data", async () => { + const body = await getMultipartBody(` + op read( + @header contentType: "multipart/mixed", + @multipartBody body: [HttpPart]): void; + `); + strictEqual(body.parts.length, 1); + strictEqual(body.parts[0].name, undefined); + }); + }); + + it("resolve name from HttpPart options", async () => { + const body = await getMultipartBody(` + op read( + @header contentType: "multipart/mixed", + @multipartBody body: [HttpPart]): void; + `); + strictEqual(body.parts.length, 1); + strictEqual(body.parts[0].name, "myPart"); + }); + + it("using an array of parts marks the part as multi: true", async () => { + const body = await getMultipartBody(` + op read( + @header contentType: "multipart/mixed", + @multipartBody body: [ + HttpPart[] + ]): void; + `); + + strictEqual(body.parts.length, 1); + strictEqual(body.parts[0].multi, true); + }); + + it("emit diagnostic if using non HttpPart in tuple", async () => { + const diagnostics = await diagnoseHttpOp(` + op read( + @header contentType: "multipart/mixed", + @multipartBody body: [string]): void; + `); + expectDiagnostics(diagnostics, { + code: "@typespec/http/multipart-part", + message: "Expect item to be an HttpPart model.", + }); + }); +}); + +describe("define with the object form", () => { + it("use name from property name", async () => { + const body = await getMultipartBody(` + op read( + @header contentType: "multipart/mixed", + @multipartBody body: { + myPropertyPart: HttpPart + }): void; + `); + + strictEqual(body.parts.length, 1); + strictEqual(body.parts[0].name, "myPropertyPart"); + }); + + it("using an array of parts marks the part as multi: true", async () => { + const body = await getMultipartBody(` + op read( + @header contentType: "multipart/mixed", + @multipartBody body: { + part: HttpPart[] + }): void; + `); + + strictEqual(body.parts.length, 1); + strictEqual(body.parts[0].multi, true); + }); + + it("emit diagnostic if using non HttpPart in tuple", async () => { + const diagnostics = await diagnoseHttpOp(` + op read( + @header contentType: "multipart/mixed", + @multipartBody body: { part: string }): void; + `); + expectDiagnostics(diagnostics, { + code: "@typespec/http/multipart-part", + message: "Expect item to be an HttpPart model.", + }); + }); +}); + +describe("resolving part payload", () => { + it("emit diagnostic if trying to use @multipartBody inside an HttpPart", async () => { + const diagnostics = await diagnoseHttpOp(` + op read( + @header contentType: "multipart/mixed", + @multipartBody body: [HttpPart<{@multipartBody nested: []}>]): void; + `); + expectDiagnostics(diagnostics, { + code: "@typespec/http/multipart-nested", + message: "Cannot use @multipartBody inside of an HttpPart", + }); + }); + it("extract headers for the part", async () => { + const op = await getHttpOp(` + op read( + @header contentType: "multipart/mixed", + @header operationHeader: string; + @multipartBody body: [ + HttpPart<{ + @body nested: string, + @header partHeader: string; + }>]): void; + `); + + strictEqual(op.parameters.parameters.length, 2); + strictEqual(op.parameters.parameters[0].name, "Content-Type"); + strictEqual(op.parameters.parameters[1].name, "operation-header"); + + const body = op.parameters.body; + strictEqual(body?.bodyKind, "multipart"); + strictEqual(body.parts.length, 1); + strictEqual(body.parts[0].headers.length, 1); + strictEqual(body.parts[0].headers[0].options.name, "part-header"); + }); + + describe("part default content type", () => { + it.each([ + ["bytes", "application/octet-stream"], + ["File", "*/*"], + ["string", "text/plain"], + ["int32", "text/plain"], + ["string[]", "application/json"], + ["Foo", "application/json", `model Foo { value: string }`], + ])("%s body", async (type, expectedContentType, extra?: string) => { + const body = await getMultipartBody(` + op upload( + @header contentType: "multipart/mixed", + @multipartBody body: [ + HttpPart<${type}>, + HttpPart<${type}>[] + ]): void; + ${extra ?? ""} + `); + + strictEqual(body.parts.length, 2); + deepStrictEqual(body.parts[0].body.contentTypes, [expectedContentType]); + deepStrictEqual(body.parts[1].body.contentTypes, [expectedContentType]); + }); + }); +}); + +async function getHttpOp(code: string) { + const [ops, diagnostics] = await getOperationsWithServiceNamespace(code); + expectDiagnosticEmpty(diagnostics); + strictEqual(ops.length, 1); + return ops[0]; +} + +async function getMultipartBody(code: string): Promise { + const op = await getHttpOp(code); + const body = op.parameters.body; + strictEqual(body?.bodyKind, "multipart"); + return body; +} + +async function diagnoseHttpOp(code: string) { + const [_, diagnostics] = await getOperationsWithServiceNamespace(code); + return diagnostics; +} diff --git a/packages/http/test/responses.test.ts b/packages/http/test/responses.test.ts index bd830ae776..b8dc963da8 100644 --- a/packages/http/test/responses.test.ts +++ b/packages/http/test/responses.test.ts @@ -84,7 +84,7 @@ it("issues diagnostics for invalid content types", async () => { @route("/test1") @get - op test1(): { @header contentType: string, @body body: Foo }; + op test1(): { @header contentType: int32, @body body: Foo }; @route("/test2") @get op test2(): { @header contentType: 42, @body body: Foo }; @@ -106,15 +106,12 @@ it("supports any casing for string literal 'Content-Type' header properties.", a model 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("/test3") - @get op test3(): { @header "content-type": "application/json", @body body: Foo }; ` ); diff --git a/packages/http/test/test-host.ts b/packages/http/test/test-host.ts index 0280b7588a..96268bcffc 100644 --- a/packages/http/test/test-host.ts +++ b/packages/http/test/test-host.ts @@ -70,7 +70,7 @@ export async function compileOperations( params: { params: r.parameters.parameters.map(({ type, name }) => ({ type, name })), body: - r.parameters.body?.parameter?.name ?? + r.parameters.body?.property?.name ?? (r.parameters.body?.type?.kind === "Model" ? Array.from(r.parameters.body.type.properties.keys()) : undefined), diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 4cf5c04e4f..6922ab3aef 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -60,9 +60,11 @@ import { HeaderFieldOptions, HttpAuth, HttpOperation, + HttpOperationBody, + HttpOperationMultipartBody, HttpOperationParameter, HttpOperationParameters, - HttpOperationRequestBody, + HttpOperationPart, HttpOperationResponse, HttpOperationResponseContent, HttpServer, @@ -70,6 +72,7 @@ import { HttpStatusCodesEntry, HttpVerb, isContentTypeHeader, + isOrExtendsHttpFile, isOverloadSameEndpoint, MetadataInfo, QueryParameterOptions, @@ -93,10 +96,12 @@ import { buildVersionProjections, VersionProjections } from "@typespec/versionin import { stringify } from "yaml"; import { getRef } from "./decorators.js"; import { createDiagnostic, FileType, OpenAPI3EmitterOptions } from "./lib.js"; -import { getDefaultValue, OpenAPI3SchemaEmitter } from "./schema-emitter.js"; +import { getDefaultValue, isBytesKeptRaw, OpenAPI3SchemaEmitter } from "./schema-emitter.js"; import { OpenAPI3Document, + OpenAPI3Encoding, OpenAPI3Header, + OpenAPI3MediaType, OpenAPI3OAuthFlows, OpenAPI3Operation, OpenAPI3Parameter, @@ -527,8 +532,8 @@ function createOAPIEmitter( /** * Validates that common bodies are consistent and returns the minimal set that describes the differences. */ - function validateCommonBodies(ops: HttpOperation[]): HttpOperationRequestBody[] | undefined { - const allBodies = ops.map((op) => op.parameters.body) as HttpOperationRequestBody[]; + function validateCommonBodies(ops: HttpOperation[]): HttpOperationBody[] | undefined { + const allBodies = ops.map((op) => op.parameters.body) as HttpOperationBody[]; return [...new Set(allBodies)]; } @@ -588,7 +593,7 @@ function createOAPIEmitter( summary: string | undefined; verb: HttpVerb; parameters: HttpOperationParameters; - bodies: HttpOperationRequestBody[] | undefined; + bodies: HttpOperationBody[] | undefined; authentication?: Authentication; responses: Map; operations: Operation[]; @@ -692,7 +697,7 @@ function createOAPIEmitter( operations: operations.map((op) => op.operation), parameters: { parameters: [], - }, + } as any, bodies: undefined, authentication: operations[0].authentication, responses: new Map(), @@ -1016,15 +1021,7 @@ function createOAPIEmitter( } obj.content ??= {}; for (const contentType of data.body.contentTypes) { - const isBinary = isBinaryPayload(data.body.type, contentType); - const schema = isBinary - ? { type: "string", format: "binary" } - : getSchemaForBody( - data.body.type, - Visibility.Read, - data.body.isExplicit && data.body.containsMetadataAnnotations, - undefined - ); + const { schema } = getBodyContentEntry(data.body, Visibility.Read, contentType); if (schemaMap.has(contentType)) { schemaMap.get(contentType)!.push(schema); } else { @@ -1127,7 +1124,32 @@ function createOAPIEmitter( }) as any; } - function getSchemaForBody( + function getBodyContentEntry( + body: HttpOperationBody | HttpOperationMultipartBody, + visibility: Visibility, + contentType: string + ): OpenAPI3MediaType { + const isBinary = isBinaryPayload(body.type, contentType); + if (isBinary) { + return { schema: { type: "string", format: "binary" } }; + } + + switch (body.bodyKind) { + case "single": + return { + schema: getSchemaForSingleBody( + body.type, + visibility, + body.isExplicit && body.containsMetadataAnnotations, + contentType.startsWith("multipart/") ? contentType : undefined + ), + }; + case "multipart": + return getBodyContentForMultipartBody(body, visibility, contentType); + } + } + + function getSchemaForSingleBody( type: Type, visibility: Visibility, ignoreMetadataAnnotations: boolean, @@ -1142,6 +1164,119 @@ function createOAPIEmitter( ); } + function getBodyContentForMultipartBody( + body: HttpOperationMultipartBody, + visibility: Visibility, + contentType: string + ): OpenAPI3MediaType { + const properties: Record = {}; + const requiredProperties: string[] = []; + const encodings: Record = {}; + for (const [partIndex, part] of body.parts.entries()) { + const partName = part.name ?? `part${partIndex}`; + let schema = isBytesKeptRaw(program, part.body.type) + ? { type: "string", format: "binary" } + : getSchemaForSingleBody( + part.body.type, + visibility, + part.body.isExplicit && part.body.containsMetadataAnnotations, + part.body.type.kind === "Union" ? contentType : undefined + ); + + if (part.multi) { + schema = { + type: "array", + items: schema, + }; + } + properties[partName] = schema; + + const encoding = resolveEncodingForMultipartPart(part, visibility, schema); + if (encoding) { + encodings[partName] = encoding; + } + if (!part.optional) { + requiredProperties.push(partName); + } + } + + const schema: OpenAPI3Schema = { + type: "object", + properties, + required: requiredProperties, + }; + + const name = + "name" in body.type && body.type.name !== "" + ? getOpenAPITypeName(program, body.type, typeNameOptions) + : undefined; + if (name) { + root.components!.schemas![name] = schema; + } + const result: OpenAPI3MediaType = { + schema: name ? { $ref: "#/components/schemas/" + name } : schema, + }; + + if (Object.keys(encodings).length > 0) { + result.encoding = encodings; + } + return result; + } + + function resolveEncodingForMultipartPart( + part: HttpOperationPart, + visibility: Visibility, + schema: OpenAPI3Schema + ): OpenAPI3Encoding | undefined { + const encoding: OpenAPI3Encoding = {}; + if (!isDefaultContentTypeForOpenAPI3(part.body.contentTypes, schema)) { + encoding.contentType = part.body.contentTypes.join(", "); + } + const headers = part.headers; + if (headers.length > 0) { + encoding.headers = {}; + for (const header of headers) { + const schema = getOpenAPIParameterBase(header.property, visibility); + if (schema !== undefined) { + encoding.headers[header.options.name] = schema; + } + } + } + if (Object.keys(encoding).length === 0) { + return undefined; + } + return encoding; + } + + function isDefaultContentTypeForOpenAPI3( + contentTypes: string[], + schema: OpenAPI3Schema + ): boolean { + if (contentTypes.length === 0) { + return false; + } + if (contentTypes.length > 1) { + return false; + } + const contentType = contentTypes[0]; + + switch (contentType) { + case "text/plain": + return schema.type === "string" || schema.type === "number"; + case "application/octet-stream": + return ( + (schema.type === "string" && schema.format === "binary") || + (schema.type === "array" && + (schema.items as any)?.type === "string" && + (schema.items as any)?.format === "binary") + ); + case "application/json": + return schema.type === "object"; + } + + return false; + } + function getParamPlaceholder(property: ModelProperty) { let spreadParam = false; @@ -1190,10 +1325,7 @@ function createOAPIEmitter( } } - function emitMergedRequestBody( - bodies: HttpOperationRequestBody[] | undefined, - visibility: Visibility - ) { + function emitMergedRequestBody(bodies: HttpOperationBody[] | undefined, visibility: Visibility) { if (bodies === undefined) { return; } @@ -1203,7 +1335,7 @@ function createOAPIEmitter( }; const schemaMap = new Map(); for (const body of bodies) { - const desc = body.parameter ? getDoc(program, body.parameter) : undefined; + const desc = body.property ? getDoc(program, body.property) : undefined; if (desc) { requestBody.description = requestBody.description ? `${requestBody.description} ${desc}` @@ -1211,15 +1343,7 @@ function createOAPIEmitter( } const contentTypes = body.contentTypes.length > 0 ? body.contentTypes : ["application/json"]; for (const contentType of contentTypes) { - const isBinary = isBinaryPayload(body.type, contentType); - const bodySchema = isBinary - ? { type: "string", format: "binary" } - : getSchemaForBody( - body.type, - visibility, - body.isExplicit, - contentType.startsWith("multipart/") ? contentType : undefined - ); + const { schema: bodySchema } = getBodyContentEntry(body, visibility, contentType); if (schemaMap.has(contentType)) { schemaMap.get(contentType)!.push(bodySchema); } else { @@ -1241,32 +1365,23 @@ function createOAPIEmitter( currentEndpoint.requestBody = requestBody; } - function emitRequestBody(body: HttpOperationRequestBody | undefined, visibility: Visibility) { + function emitRequestBody( + body: HttpOperationBody | HttpOperationMultipartBody | undefined, + visibility: Visibility + ) { if (body === undefined || isVoidType(body.type)) { return; } const requestBody: any = { - description: body.parameter ? getDoc(program, body.parameter) : undefined, - required: body.parameter ? !body.parameter.optional : true, + description: body.property ? getDoc(program, body.property) : undefined, + required: body.property ? !body.property.optional : true, content: {}, }; const contentTypes = body.contentTypes.length > 0 ? body.contentTypes : ["application/json"]; for (const contentType of contentTypes) { - const isBinary = isBinaryPayload(body.type, contentType); - const bodySchema = isBinary - ? { type: "string", format: "binary" } - : getSchemaForBody( - body.type, - visibility, - body.isExplicit && body.containsMetadataAnnotations, - contentType.startsWith("multipart/") ? contentType : undefined - ); - const contentEntry: any = { - schema: bodySchema, - }; - requestBody.content[contentType] = contentEntry; + requestBody.content[contentType] = getBodyContentEntry(body, visibility, contentType); } currentEndpoint.requestBody = requestBody; @@ -1468,6 +1583,9 @@ function createOAPIEmitter( function processUnreferencedSchemas() { const addSchema = (type: Type) => { + if (isOrExtendsHttpFile(program, type)) { + return; + } if ( visibilityUsage.isUnreachable(type) && !paramModels.has(type) && diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index 3413b284c1..44f10ae4a5 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -325,24 +325,17 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< return props; } - #isBytesKeptRaw(type: Type) { - const program = this.emitter.getProgram(); - return ( - type.kind === "Scalar" && type.name === "bytes" && getEncode(program, type) === undefined - ); - } - modelPropertyLiteral(prop: ModelProperty): EmitterOutput { const program = this.emitter.getProgram(); const isMultipart = this.#getContentType().startsWith("multipart/"); if (isMultipart) { - if (this.#isBytesKeptRaw(prop.type) && getEncode(program, prop) === undefined) { + if (isBytesKeptRaw(program, prop.type) && getEncode(program, prop) === undefined) { return { type: "string", format: "binary" }; } if ( prop.type.kind === "Model" && isArrayModelType(program, prop.type) && - this.#isBytesKeptRaw(prop.type.indexer.value) + isBytesKeptRaw(program, prop.type.indexer.value) ) { return { type: "array", items: { type: "string", format: "binary" } }; } @@ -352,7 +345,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< referenceContext: isMultipart && (prop.type.kind !== "Union" || - ![...prop.type.variants.values()].some((x) => this.#isBytesKeptRaw(x.type))) + ![...prop.type.variants.values()].some((x) => isBytesKeptRaw(program, x.type))) ? { contentType: "application/json" } : {}, }); @@ -494,7 +487,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< continue; } - if (isMultipart && this.#isBytesKeptRaw(variant.type)) { + if (isMultipart && isBytesKeptRaw(program, variant.type)) { schemaMembers.push({ schema: { type: "string", format: "binary" }, type: variant.type }); continue; } @@ -1012,3 +1005,7 @@ export function getDefaultValue(program: Program, defaultType: Value): any { }); } } + +export function isBytesKeptRaw(program: Program, type: Type) { + return type.kind === "Scalar" && type.name === "bytes" && getEncode(program, type) === undefined; +} diff --git a/packages/openapi3/test/multipart.test.ts b/packages/openapi3/test/multipart.test.ts index 9301cb71d1..c7f8728b57 100644 --- a/packages/openapi3/test/multipart.test.ts +++ b/packages/openapi3/test/multipart.test.ts @@ -1,8 +1,216 @@ import { deepStrictEqual } from "assert"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; +import { OpenAPI3Encoding, OpenAPI3Schema } from "../src/types.js"; import { openApiFor } from "./test-host.js"; -describe("typespec-autorest: multipart", () => { +it("create dedicated model for multipart", async () => { + const res = await openApiFor( + ` + model Form { name: HttpPart, profileImage: HttpPart } + op upload(@header contentType: "multipart/form-data", @multipartBody body: Form): void; + ` + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + $ref: "#/components/schemas/Form", + }, + }); +}); + +it("part of type `bytes` produce `type: string, format: binary`", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", @multipartBody body: { profileImage: HttpPart }): void; + ` + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + profileImage: { + format: "binary", + type: "string", + }, + }, + required: ["profileImage"], + }, + }); +}); + +it("part of type union `HttpPart` produce `type: string, format: binary`", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", @multipartBody _: {profileImage: HttpPart}): void; + ` + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + profileImage: { + anyOf: [ + { + type: "string", + format: "binary", + }, + { + type: "object", + properties: { + content: { type: "string", format: "byte" }, + }, + required: ["content"], + }, + ], + }, + }, + required: ["profileImage"], + }, + encoding: { + profileImage: { + contentType: "application/octet-stream, application/json", + }, + }, + }); +}); + +it("part of type `bytes[]` produce `type: array, items: {type: string, format: binary}`", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", @multipartBody _: { profileImage: HttpPart[]}): void; + ` + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + profileImage: { + type: "array", + items: { type: "string", format: "binary" }, + }, + }, + required: ["profileImage"], + }, + }); +}); + +it("part of type `string` produce `type: string`", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", @multipartBody _: { name: HttpPart }): void; + ` + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + name: { + type: "string", + }, + }, + required: ["name"], + }, + }); +}); + +it("part of type `object` produce an object", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", @multipartBody _: { address: HttpPart<{city: string, street: string}>}): void; + ` + ); + const op = res.paths["/"].post; + deepStrictEqual(op.requestBody.content["multipart/form-data"], { + schema: { + type: "object", + properties: { + address: { + type: "object", + properties: { + city: { + type: "string", + }, + street: { + type: "string", + }, + }, + required: ["city", "street"], + }, + }, + required: ["address"], + }, + }); +}); + +it("bytes inside a json part will be treated as base64 encoded by default(same as for a json body)", async () => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", @multipartBody _: { address: HttpPart<{city: string, icon: bytes}> }): void; + ` + ); + const op = res.paths["/"].post; + deepStrictEqual( + op.requestBody.content["multipart/form-data"].schema.properties.address.properties.icon, + { + type: "string", + format: "byte", + } + ); +}); + +describe("part mapping", () => { + it.each([ + [`string`, { type: "string" }], + [`bytes`, { type: "string", format: "binary" }], + [`string[]`, { type: "array", items: { type: "string" } }, { contentType: "application/json" }], + [ + `bytes[]`, + { type: "array", items: { type: "string", format: "byte" } }, + { contentType: "application/json" }, + ], + [ + `{@header contentType: "image/png", @body image: bytes}`, + { type: "string", format: "binary" }, + { contentType: "image/png" }, + ], + [`File`, { type: "string", format: "binary" }, { contentType: "*/*" }], + [ + `{@header extra: string, @body body: string}`, + { type: "string" }, + { + headers: { + extra: { + required: true, + schema: { + type: "string", + }, + }, + }, + }, + ], + ] satisfies [string, OpenAPI3Schema, OpenAPI3Encoding?][])( + `HttpPart<%s>`, + async (type: string, expectedSchema: OpenAPI3Schema, expectedEncoding?: OpenAPI3Encoding) => { + const res = await openApiFor( + ` + op upload(@header contentType: "multipart/form-data", @multipartBody _: { part: HttpPart<${type}> }): void; + ` + ); + const content = res.paths["/"].post.requestBody.content["multipart/form-data"]; + expect(content.schema.properties.part).toEqual(expectedSchema); + + if (expectedEncoding || content.encoding?.part) { + expect(content.encoding?.part).toEqual(expectedEncoding); + } + } + ); +}); + +describe("legacy implicit form", () => { it("add MultiPart suffix to model name", async () => { const res = await openApiFor( ` diff --git a/packages/rest/test/test-host.ts b/packages/rest/test/test-host.ts index 23fca47899..64947cf3a5 100644 --- a/packages/rest/test/test-host.ts +++ b/packages/rest/test/test-host.ts @@ -71,7 +71,7 @@ export async function compileOperations( params: { params: r.parameters.parameters.map(({ type, name }) => ({ type, name })), body: - r.parameters.body?.parameter?.name ?? + r.parameters.body?.property?.name ?? (r.parameters.body?.type.kind === "Model" ? Array.from(r.parameters.body?.type.properties.keys()) : undefined), diff --git a/packages/samples/specs/multipart/main.tsp b/packages/samples/specs/multipart/main.tsp index c16fb063d5..b6e3460837 100644 --- a/packages/samples/specs/multipart/main.tsp +++ b/packages/samples/specs/multipart/main.tsp @@ -2,10 +2,15 @@ import "@typespec/rest"; using TypeSpec.Http; +model Jpeg extends File { + contentType: "image/jpeg"; +} + model Data { - id: string; - profileImage: bytes; - address: Address; + id: HttpPart; + profileImage: HttpPart; + photos: HttpPart[]; + address: HttpPart
; } model Address { @@ -13,4 +18,4 @@ model Address { street: string; } -op basic(@header contentType: "multipart/form-data", @body body: Data): string; +op basic(@header contentType: "multipart/form-data", @multipartBody body: Data): string; diff --git a/packages/samples/specs/rest-metadata-emitter/rest-metadata-emitter-sample.ts b/packages/samples/specs/rest-metadata-emitter/rest-metadata-emitter-sample.ts index 78a4d404a0..703986e01f 100644 --- a/packages/samples/specs/rest-metadata-emitter/rest-metadata-emitter-sample.ts +++ b/packages/samples/specs/rest-metadata-emitter/rest-metadata-emitter-sample.ts @@ -18,6 +18,7 @@ import { getVisibilitySuffix, HttpOperation, HttpOperationBody, + HttpOperationMultipartBody, HttpOperationResponse, resolveRequestVisibility, Visibility, @@ -247,7 +248,9 @@ export async function $onEmit(context: EmitContext): Promise { return remarks.length === 0 ? "" : ` (${remarks.join(", ")})`; } - function getContentTypeRemark(body: HttpOperationBody | undefined) { + function getContentTypeRemark( + body: HttpOperationBody | HttpOperationMultipartBody | undefined + ) { const ct = body?.contentTypes; if (!ct || ct.length === 0 || (ct.length === 1 && ct[0] === "application/json")) { return ""; diff --git a/packages/samples/test/output/multipart/@typespec/openapi3/openapi.yaml b/packages/samples/test/output/multipart/@typespec/openapi3/openapi.yaml index 08b373c5f8..786dee2741 100644 --- a/packages/samples/test/output/multipart/@typespec/openapi3/openapi.yaml +++ b/packages/samples/test/output/multipart/@typespec/openapi3/openapi.yaml @@ -20,7 +20,14 @@ paths: content: multipart/form-data: schema: - $ref: '#/components/schemas/DataMultiPart' + $ref: '#/components/schemas/Data' + encoding: + profileImage: + contentType: '*/*' + photos: + contentType: image/jpeg + address: + contentType: application/json components: schemas: Address: @@ -33,17 +40,23 @@ components: type: string street: type: string - DataMultiPart: + Data: type: object - required: - - id - - profileImage - - address properties: id: type: string profileImage: type: string format: binary + photos: + type: array + items: + type: string + format: binary address: $ref: '#/components/schemas/Address' + required: + - id + - profileImage + - photos + - address diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 959f05ce29..86af1eea61 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -1,9 +1,11 @@ +import { defineConfig } from "vitest/config"; + export default ["packages/*/vitest.config.ts", "packages/*/vitest.config.mts"]; /** * Default Config For all TypeSpec projects using vitest. */ -export const defaultTypeSpecVitestConfig = { +export const defaultTypeSpecVitestConfig = defineConfig({ test: { environment: "node", isolate: false, @@ -14,8 +16,6 @@ export const defaultTypeSpecVitestConfig = { junit: "./test-results.xml", }, watchExclude: [], + exclude: ["node_modules", "dist/test"], }, - build: { - outDir: "dummy", // Workaround for bug https://github.com/vitest-dev/vitest/issues/5429 - }, -}; +});