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, Options>` 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
This commit is contained in:
Родитель
75f407c0ba
Коммит
40df1ec9a3
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
changeKind: internal
|
||||
packages:
|
||||
- "@typespec/rest"
|
||||
---
|
||||
|
|
@ -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<Type, Options>`. 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<string>;
|
||||
avatar: HttpPart<bytes>[];
|
||||
}): void;
|
||||
```
|
|
@ -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
|
|
@ -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": {
|
||||
|
|
|
@ -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<Type, Options>
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
|
|
@ -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<string>;
|
||||
headShots: HttpPart<Image>[];
|
||||
},
|
||||
): void;
|
||||
```
|
||||
|
||||
### `@patch` {#@TypeSpec.Http.patch}
|
||||
|
||||
Specify the HTTP verb for the target operation to be `PATCH`.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<string>;
|
||||
headShots: HttpPart<Image>[];
|
||||
},
|
||||
): void;
|
||||
```
|
||||
|
||||
#### `@patch`
|
||||
|
||||
Specify the HTTP verb for the target operation to be `PATCH`.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<string>,
|
||||
* headShots: HttpPart<Image>[]
|
||||
* }
|
||||
* ): void;
|
||||
* ```
|
||||
*/
|
||||
export type MultipartBodyDecorator = (context: DecoratorContext, target: ModelProperty) => void;
|
||||
|
||||
/**
|
||||
* Specify the HTTP verb for the target operation to be `GET`.
|
||||
*
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<string>,
|
||||
* headShots: HttpPart<Image>[]
|
||||
* }
|
||||
* ): 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);
|
||||
}
|
|
@ -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> {
|
||||
...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<Type, Options extends valueof HttpPartOptions = #{}> {}
|
||||
|
|
|
@ -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
|
||||
);
|
|
@ -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<ModelProperty>,
|
||||
rootPropertyMap: Map<ModelProperty, ModelProperty>,
|
||||
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<string, Type>();
|
||||
|
||||
// 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);
|
||||
}
|
|
@ -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 })]];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<T extends HttpProperty>(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<HttpProperty[]> {
|
||||
const diagnostics = createDiagnosticCollector();
|
||||
const httpProperties = new Map<ModelProperty, HttpProperty>();
|
||||
|
||||
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;
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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<ModelProperty, ModelProperty>
|
||||
): Set<ModelProperty> {
|
||||
const metadata = new Map<string, ModelProperty>();
|
||||
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) {
|
||||
|
|
|
@ -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<ModelProperty, ModelProperty>();
|
||||
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];
|
||||
}
|
||||
|
|
|
@ -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<string, Type>();
|
||||
|
||||
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))];
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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<ModelProperty, ModelProperty>();
|
||||
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<ModelProperty>
|
||||
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<ModelProperty>
|
||||
): 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<ModelProperty>
|
||||
metadata: HttpProperty[]
|
||||
): Record<string, ModelProperty> {
|
||||
const responseHeaders: Record<string, ModelProperty> = {};
|
||||
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
|
||||
|
|
|
@ -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<string, ModelProperty>;
|
||||
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 {
|
||||
|
|
|
@ -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<string>]): 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<string>]): 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<string>]): 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<string, #{name: "myPart"}>]): 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<string>[]
|
||||
]): 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<string, #{name: "myPart"}>
|
||||
}): 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<string>[]
|
||||
}): 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<HttpOperationMultipartBody> {
|
||||
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;
|
||||
}
|
|
@ -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 };
|
||||
`
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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<string, HttpOperationResponse[]>;
|
||||
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<string, HttpOperationResponse[]>(),
|
||||
|
@ -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<string, OpenAPI3Schema> = {};
|
||||
const requiredProperties: string[] = [];
|
||||
const encodings: Record<string, OpenAPI3Encoding> = {};
|
||||
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<string, any[]>();
|
||||
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) &&
|
||||
|
|
|
@ -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<object> {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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<string>, profileImage: HttpPart<bytes> }
|
||||
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<bytes> }): 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<bytes | {content: bytes}>` produce `type: string, format: binary`", async () => {
|
||||
const res = await openApiFor(
|
||||
`
|
||||
op upload(@header contentType: "multipart/form-data", @multipartBody _: {profileImage: HttpPart<bytes | {content: bytes}>}): 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<bytes>[]}): 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<string> }): 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(
|
||||
`
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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<string>;
|
||||
profileImage: HttpPart<File>;
|
||||
photos: HttpPart<Jpeg>[];
|
||||
address: HttpPart<Address>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
getVisibilitySuffix,
|
||||
HttpOperation,
|
||||
HttpOperationBody,
|
||||
HttpOperationMultipartBody,
|
||||
HttpOperationResponse,
|
||||
resolveRequestVisibility,
|
||||
Visibility,
|
||||
|
@ -247,7 +248,9 @@ export async function $onEmit(context: EmitContext): Promise<void> {
|
|||
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 "";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче