fix https://github.com/microsoft/typespec/issues/3736

---------

Co-authored-by: Christopher Radek <14189820+chrisradek@users.noreply.github.com>
This commit is contained in:
Timothee Guerin 2024-08-06 11:49:54 -07:00 коммит произвёл GitHub
Родитель 8a3e94123c
Коммит e492ff7d7b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
32 изменённых файлов: 1042 добавлений и 282 удалений

Просмотреть файл

@ -0,0 +1,12 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: feature
packages:
- "@typespec/http"
---
`@route` can now take a uri template as defined by [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)
```tsp
@route("files{+path}") download(path: string): void;
```

Просмотреть файл

@ -0,0 +1,9 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: feature
packages:
- "@typespec/openapi3"
- "@typespec/rest"
---
Add support for URI templates in routes

Просмотреть файл

@ -0,0 +1,7 @@
---
changeKind: deprecation
packages:
- "@typespec/http"
---
API deprecation: `HttpOperation#pathSegments` is deprecated. Use `HttpOperation#uriTemplate` instead.

Просмотреть файл

@ -0,0 +1,13 @@
---
changeKind: deprecation
packages:
- "@typespec/http"
---
Deprecated `@query({format: })` option. Use `@query(#{explode: true})` instead of `form` or `multi` format. Previously `csv`/`simple` is the default now.
Decorator is also expecting an object value now instead of a model. A deprecation warning with a codefix will help migrating.
```diff
- @query({format: "form"}) select: string[];
+ @query(#{explode: true}) select: string[];
```

Просмотреть файл

@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: feature
packages:
- "@typespec/compiler"
---
Add `ArrayEncoding` enum to define simple serialization of arrays

Просмотреть файл

@ -466,6 +466,21 @@ model TypeSpec.Http.PasswordFlow
| refreshUrl? | `string` | the refresh URL |
| scopes? | `string[]` | list of scopes for the credential |
### `PathOptions` {#TypeSpec.Http.PathOptions}
```typespec
model TypeSpec.Http.PathOptions
```
#### Properties
| Name | Type | Description |
| -------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| name? | `string` | Name of the parameter in the uri template. |
| explode? | `boolean` | When interpolating this parameter in the case of array or object expand each value using the given style.<br />Equivalent of adding `*` in the path parameter as per [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3) |
| style? | `"simple" \| "label" \| "matrix" \| "fragment" \| "path"` | Different interpolating styles for the path parameter.<br />- `simple`: No special encoding.<br />- `label`: Using `.` separator.<br />- `matrix`: `;` as separator.<br />- `fragment`: `#` as separator.<br />- `path`: `/` as separator. |
| allowReserved? | `boolean` | When interpolating this parameter do not encode reserved characters.<br />Equivalent of adding `+` in the path parameter as per [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3) |
### `PlainData` {#TypeSpec.Http.PlainData}
Produces a new model with the same properties as T, but with `@query`,
@ -495,10 +510,11 @@ model TypeSpec.Http.QueryOptions
#### Properties
| Name | Type | Description |
| ------- | --------------------------------------------------------------------- | --------------------------------------------------------- |
| name? | `string` | Name of the query when included in the url. |
| format? | `"multi" \| "csv" \| "ssv" \| "tsv" \| "simple" \| "form" \| "pipes"` | Determines the format of the array if type array is used. |
| Name | Type | Description |
| -------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| name? | `string` | Name of the query when included in the url. |
| explode? | `boolean` | If true send each value in the array/object as a separate query parameter.<br />Equivalent of adding `*` in the path parameter as per [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)<br /><br />\| Style \| Explode \| Uri Template \| Primitive value id = 5 \| Array id = [3, 4, 5] \| Object id = {"role": "admin", "firstName": "Alex"} \|<br />\| ------ \| ------- \| -------------- \| ---------------------- \| ----------------------- \| -------------------------------------------------- \|<br />\| simple \| false \| `/users{?id}` \| `/users?id=5` \| `/users?id=3,4,5` \| `/users?id=role,admin,firstName,Alex` \|<br />\| simple \| true \| `/users{?id*}` \| `/users?id=5` \| `/users?id=3&id=4&id=5` \| `/users?role=admin&firstName=Alex` \| |
| format? | `"multi" \| "csv" \| "ssv" \| "tsv" \| "simple" \| "form" \| "pipes"` | Determines the format of the array if type array is used.<br />**DEPRECATED**: use explode: true instead of `multi` or `@encode` |
### `Response` {#TypeSpec.Http.Response}

Просмотреть файл

@ -278,7 +278,7 @@ None
Explicitly specify that this property is to be interpolated as a path parameter.
```typespec
@TypeSpec.Http.path(paramName?: valueof string)
@TypeSpec.Http.path(paramNameOrOptions?: valueof string | TypeSpec.Http.PathOptions)
```
#### Target
@ -287,9 +287,9 @@ Explicitly specify that this property is to be interpolated as a path parameter.
#### Parameters
| Name | Type | Description |
| --------- | ---------------- | --------------------------------------------------- |
| paramName | `valueof string` | Optional name of the parameter in the url template. |
| Name | Type | Description |
| ------------------ | --------------------------------------------- | -------------------------------------------------------------- |
| paramNameOrOptions | `valueof string \| TypeSpec.Http.PathOptions` | Optional name of the parameter in the uri template or options. |
#### Examples
@ -347,7 +347,7 @@ None
Specify this property is to be sent as a query parameter.
```typespec
@TypeSpec.Http.query(queryNameOrOptions?: string | TypeSpec.Http.QueryOptions)
@TypeSpec.Http.query(queryNameOrOptions?: valueof string | TypeSpec.Http.QueryOptions)
```
#### Target
@ -356,30 +356,20 @@ Specify this property is to be sent as a query parameter.
#### Parameters
| Name | Type | Description |
| ------------------ | -------------------------------------- | ------------------------------------------------------------------------------- |
| queryNameOrOptions | `string \| TypeSpec.Http.QueryOptions` | Optional name of the query when included in the url or query parameter options. |
| Name | Type | Description |
| ------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------- |
| queryNameOrOptions | `valueof string \| TypeSpec.Http.QueryOptions` | Optional name of the query when included in the url or query parameter options. |
#### Examples
```typespec
op read(@query select: string, @query("order-by") orderBy: string): void;
op list(
@query({
name: "id",
format: "multi",
})
ids: string[],
): void;
op list(@query(#{ name: "id", explode: true }) ids: string[]): void;
```
### `@route` {#@TypeSpec.Http.route}
Defines the relative route URI for the target operation
The first argument should be a URI fragment that may contain one or more path parameter fields.
If the namespace or interface that contains the operation is also marked with a `@route` decorator,
it will be used as a prefix to the route URI of the operation.
Defines the relative route URI template for the target operation as defined by [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)
`@route` can only be applied to operations, namespaces, and interfaces.
@ -393,16 +383,30 @@ it will be used as a prefix to the route URI of the operation.
#### Parameters
| Name | Type | Description |
| ------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| path | `valueof string` | Relative route path. Cannot include query parameters. |
| options | `{...}` | Set of parameters used to configure the route. Supports `{shared: true}` which indicates that the route may be shared by several operations. |
| Name | Type | Description |
| ------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| path | `valueof string` | |
| options | `{...}` | _DEPRECATED_ Set of parameters used to configure the route. Supports `{shared: true}` which indicates that the route may be shared by several operations. |
#### Examples
##### Simple path parameter
```typespec
@route("/widgets")
op getWidget(@path id: string): Widget;
@route("/widgets/{id}") op getWidget(@path id: string): Widget;
```
##### Reserved characters
```typespec
@route("/files{+path}") op getFile(@path path: string): bytes;
```
##### Query parameter
```typespec
@route("/files") op list(select?: string, filter?: string): Files[];
@route("/files{?select,filter}") op listFullUriTemplate(select?: string, filter?: string): Files[];
```
### `@server` {#@TypeSpec.Http.server}

Просмотреть файл

@ -83,6 +83,7 @@ npm install --save-peer @typespec/http
- [`OkResponse`](./data-types.md#TypeSpec.Http.OkResponse)
- [`OpenIdConnectAuth`](./data-types.md#TypeSpec.Http.OpenIdConnectAuth)
- [`PasswordFlow`](./data-types.md#TypeSpec.Http.PasswordFlow)
- [`PathOptions`](./data-types.md#TypeSpec.Http.PathOptions)
- [`PlainData`](./data-types.md#TypeSpec.Http.PlainData)
- [`QueryOptions`](./data-types.md#TypeSpec.Http.QueryOptions)
- [`Response`](./data-types.md#TypeSpec.Http.Response)

Просмотреть файл

@ -194,6 +194,19 @@ model UpdateableProperties<Source>
#### Properties
None
### `ArrayEncoding` {#ArrayEncoding}
Encoding for serializing arrays
```typespec
enum ArrayEncoding
```
| Name | Value | Description |
|------|-------|-------------|
| pipeDelimited | | Each values of the array is separated by a \| |
| spaceDelimited | | Each values of the array is separated by a <space> |
### `BytesKnownEncoding` {#BytesKnownEncoding}
Known encoding to use on bytes

Просмотреть файл

@ -475,6 +475,17 @@ enum BytesKnownEncoding {
base64url: "base64url",
}
/**
* Encoding for serializing arrays
*/
enum ArrayEncoding {
/** Each values of the array is separated by a | */
pipeDelimited,
/** Each values of the array is separated by a <space> */
spaceDelimited,
}
/**
* Specify how to encode the target type.
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric types to encode as string).

Просмотреть файл

@ -326,7 +326,7 @@ None
Explicitly specify that this property is to be interpolated as a path parameter.
```typespec
@TypeSpec.Http.path(paramName?: valueof string)
@TypeSpec.Http.path(paramNameOrOptions?: valueof string | TypeSpec.Http.PathOptions)
```
##### Target
@ -335,9 +335,9 @@ Explicitly specify that this property is to be interpolated as a path parameter.
##### Parameters
| Name | Type | Description |
| --------- | ---------------- | --------------------------------------------------- |
| paramName | `valueof string` | Optional name of the parameter in the url template. |
| Name | Type | Description |
| ------------------ | --------------------------------------------- | -------------------------------------------------------------- |
| paramNameOrOptions | `valueof string \| TypeSpec.Http.PathOptions` | Optional name of the parameter in the uri template or options. |
##### Examples
@ -395,7 +395,7 @@ None
Specify this property is to be sent as a query parameter.
```typespec
@TypeSpec.Http.query(queryNameOrOptions?: string | TypeSpec.Http.QueryOptions)
@TypeSpec.Http.query(queryNameOrOptions?: valueof string | TypeSpec.Http.QueryOptions)
```
##### Target
@ -404,30 +404,20 @@ Specify this property is to be sent as a query parameter.
##### Parameters
| Name | Type | Description |
| ------------------ | -------------------------------------- | ------------------------------------------------------------------------------- |
| queryNameOrOptions | `string \| TypeSpec.Http.QueryOptions` | Optional name of the query when included in the url or query parameter options. |
| Name | Type | Description |
| ------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------- |
| queryNameOrOptions | `valueof string \| TypeSpec.Http.QueryOptions` | Optional name of the query when included in the url or query parameter options. |
##### Examples
```typespec
op read(@query select: string, @query("order-by") orderBy: string): void;
op list(
@query({
name: "id",
format: "multi",
})
ids: string[],
): void;
op list(@query(#{ name: "id", explode: true }) ids: string[]): void;
```
#### `@route`
Defines the relative route URI for the target operation
The first argument should be a URI fragment that may contain one or more path parameter fields.
If the namespace or interface that contains the operation is also marked with a `@route` decorator,
it will be used as a prefix to the route URI of the operation.
Defines the relative route URI template for the target operation as defined by [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)
`@route` can only be applied to operations, namespaces, and interfaces.
@ -441,16 +431,30 @@ it will be used as a prefix to the route URI of the operation.
##### Parameters
| Name | Type | Description |
| ------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| path | `valueof string` | Relative route path. Cannot include query parameters. |
| options | `{...}` | Set of parameters used to configure the route. Supports `{shared: true}` which indicates that the route may be shared by several operations. |
| Name | Type | Description |
| ------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| path | `valueof string` | |
| options | `{...}` | _DEPRECATED_ Set of parameters used to configure the route. Supports `{shared: true}` which indicates that the route may be shared by several operations. |
##### Examples
###### Simple path parameter
```typespec
@route("/widgets")
op getWidget(@path id: string): Widget;
@route("/widgets/{id}") op getWidget(@path id: string): Widget;
```
###### Reserved characters
```typespec
@route("/files{+path}") op getFile(@path path: string): bytes;
```
###### Query parameter
```typespec
@route("/files") op list(select?: string, filter?: string): Files[];
@route("/files{?select,filter}") op listFullUriTemplate(select?: string, filter?: string): Files[];
```
#### `@server`

Просмотреть файл

@ -7,6 +7,19 @@ import type {
Type,
} from "@typespec/compiler";
export interface QueryOptions {
readonly name?: string;
readonly explode?: boolean;
readonly format?: "multi" | "csv" | "ssv" | "tsv" | "simple" | "form" | "pipes";
}
export interface PathOptions {
readonly name?: string;
readonly explode?: boolean;
readonly style?: "simple" | "label" | "matrix" | "fragment" | "path";
readonly allowReserved?: boolean;
}
/**
* Specify the status code for this response. Property type must be a status code integer or a union of status code integer.
*
@ -67,19 +80,19 @@ export type HeaderDecorator = (
* @example
* ```typespec
* op read(@query select: string, @query("order-by") orderBy: string): void;
* op list(@query({name: "id", format: "multi"}) ids: string[]): void;
* op list(@query(#{name: "id", explode: true}) ids: string[]): void;
* ```
*/
export type QueryDecorator = (
context: DecoratorContext,
target: ModelProperty,
queryNameOrOptions?: Type
queryNameOrOptions?: string | QueryOptions
) => void;
/**
* Explicitly specify that this property is to be interpolated as a path parameter.
*
* @param paramName Optional name of the parameter in the url template.
* @param paramNameOrOptions Optional name of the parameter in the uri template or options.
* @example
* ```typespec
* @route("/read/{explicit}/things/{implicit}")
@ -89,7 +102,7 @@ export type QueryDecorator = (
export type PathDecorator = (
context: DecoratorContext,
target: ModelProperty,
paramName?: string
paramNameOrOptions?: string | PathOptions
) => void;
/**
@ -260,20 +273,25 @@ export type IncludeInapplicableMetadataInPayloadDecorator = (
) => void;
/**
* Defines the relative route URI for the target operation
*
* The first argument should be a URI fragment that may contain one or more path parameter fields.
* If the namespace or interface that contains the operation is also marked with a `@route` decorator,
* it will be used as a prefix to the route URI of the operation.
* Defines the relative route URI template for the target operation as defined by [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)
*
* `@route` can only be applied to operations, namespaces, and interfaces.
*
* @param path Relative route path. Cannot include query parameters.
* @param options Set of parameters used to configure the route. Supports `{shared: true}` which indicates that the route may be shared by several operations.
* @example
* @param uriTemplate Uri template for this operation.
* @param options _DEPRECATED_ Set of parameters used to configure the route. Supports `{shared: true}` which indicates that the route may be shared by several operations.
* @example Simple path parameter
*
* ```typespec
* @route("/widgets")
* op getWidget(@path id: string): Widget;
* @route("/widgets/{id}") op getWidget(@path id: string): Widget;
* ```
* @example Reserved characters
* ```typespec
* @route("/files{+path}") op getFile(@path path: string): bytes;
* ```
* @example Query parameter
* ```typespec
* @route("/files") op list(select?: string, filter?: string): Files[];
* @route("/files{?select,filter}") op listFullUriTemplate(select?: string, filter?: string): Files[];
* ```
*/
export type RouteDecorator = (

Просмотреть файл

@ -48,8 +48,21 @@ model QueryOptions {
*/
name?: string;
/**
* If true send each value in the array/object as a separate query parameter.
* Equivalent of adding `*` in the path parameter as per [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)
*
* | Style | Explode | Uri Template | Primitive value id = 5 | Array id = [3, 4, 5] | Object id = {"role": "admin", "firstName": "Alex"} |
* | ------ | ------- | -------------- | ---------------------- | ----------------------- | -------------------------------------------------- |
* | simple | false | `/users{?id}` | `/users?id=5` | `/users?id=3,4,5` | `/users?id=role,admin,firstName,Alex` |
* | simple | true | `/users{?id*}` | `/users?id=5` | `/users?id=3&id=4&id=5` | `/users?role=admin&firstName=Alex` |
*
*/
explode?: boolean;
/**
* Determines the format of the array if type array is used.
* **DEPRECATED**: use explode: true instead of `multi` or `@encode`
*/
format?: "multi" | "csv" | "ssv" | "tsv" | "simple" | "form" | "pipes";
}
@ -63,15 +76,42 @@ model QueryOptions {
*
* ```typespec
* op read(@query select: string, @query("order-by") orderBy: string): void;
* op list(@query({name: "id", format: "multi"}) ids: string[]): void;
* op list(@query(#{name: "id", explode: true}) ids: string[]): void;
* ```
*/
extern dec query(target: ModelProperty, queryNameOrOptions?: string | QueryOptions);
extern dec query(target: ModelProperty, queryNameOrOptions?: valueof string | QueryOptions);
model PathOptions {
/** Name of the parameter in the uri template. */
name?: string;
/**
* When interpolating this parameter in the case of array or object expand each value using the given style.
* Equivalent of adding `*` in the path parameter as per [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)
*/
explode?: boolean;
/**
* Different interpolating styles for the path parameter.
* - `simple`: No special encoding.
* - `label`: Using `.` separator.
* - `matrix`: `;` as separator.
* - `fragment`: `#` as separator.
* - `path`: `/` as separator.
*/
style?: "simple" | "label" | "matrix" | "fragment" | "path";
/**
* When interpolating this parameter do not encode reserved characters.
* Equivalent of adding `+` in the path parameter as per [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)
*/
allowReserved?: boolean;
}
/**
* Explicitly specify that this property is to be interpolated as a path parameter.
*
* @param paramName Optional name of the parameter in the url template.
* @param paramNameOrOptions Optional name of the parameter in the uri template or options.
*
* @example
*
@ -80,7 +120,7 @@ extern dec query(target: ModelProperty, queryNameOrOptions?: string | QueryOptio
* op read(@path explicit: string, implicit: string): void;
* ```
*/
extern dec path(target: ModelProperty, paramName?: valueof string);
extern dec path(target: ModelProperty, paramNameOrOptions?: valueof string | PathOptions);
/**
* Explicitly specify that this property type will be exactly the HTTP body.
@ -282,22 +322,28 @@ extern dec useAuth(target: Namespace | Interface | Operation, auth: {} | Union |
extern dec includeInapplicableMetadataInPayload(target: unknown, value: valueof boolean);
/**
* Defines the relative route URI for the target operation
*
* The first argument should be a URI fragment that may contain one or more path parameter fields.
* If the namespace or interface that contains the operation is also marked with a `@route` decorator,
* it will be used as a prefix to the route URI of the operation.
* Defines the relative route URI template for the target operation as defined by [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)
*
* `@route` can only be applied to operations, namespaces, and interfaces.
*
* @param path Relative route path. Cannot include query parameters.
* @param options Set of parameters used to configure the route. Supports `{shared: true}` which indicates that the route may be shared by several operations.
* @param uriTemplate Uri template for this operation.
* @param options _DEPRECATED_ Set of parameters used to configure the route. Supports `{shared: true}` which indicates that the route may be shared by several operations.
*
* @example
* @example Simple path parameter
*
* ```typespec
* @route("/widgets")
* op getWidget(@path id: string): Widget;
* @route("/widgets/{id}") op getWidget(@path id: string): Widget;
* ```
*
* @example Reserved characters
* ```typespec
* @route("/files{+path}") op getFile(@path path: string): bytes;
* ```
*
* @example Query parameter
* ```typespec
* @route("/files") op list(select?: string, filter?: string): Files[];
* @route("/files{?select,filter}") op listFullUriTemplate(select?: string, filter?: string): Files[];
* ```
*/
extern dec route(

Просмотреть файл

@ -33,9 +33,11 @@ import {
MultipartBodyDecorator,
PatchDecorator,
PathDecorator,
PathOptions,
PostDecorator,
PutDecorator,
QueryDecorator,
QueryOptions,
RouteDecorator,
ServerDecorator,
SharedRouteDecorator,
@ -123,38 +125,28 @@ export function isHeader(program: Program, entity: Type) {
export const $query: QueryDecorator = (
context: DecoratorContext,
entity: ModelProperty,
queryNameOrOptions?: StringLiteral | Type
queryNameOrOptions?: string | QueryOptions
) => {
const paramName =
typeof queryNameOrOptions === "string"
? queryNameOrOptions
: (queryNameOrOptions?.name ?? entity.name);
const userOptions: QueryOptions =
typeof queryNameOrOptions === "object" ? queryNameOrOptions : {};
if (userOptions.format) {
reportDeprecated(
context.program,
"The `format` option of `@query` decorator is deprecated. Use `explode: true` instead of `form` and `multi`. `csv` or `simple` is the default now.",
entity
);
}
const options: QueryParameterOptions = {
type: "query",
name: entity.name,
explode:
userOptions.explode ?? (userOptions.format === "multi" || userOptions.format === "form"),
format: userOptions.format ?? (userOptions.explode ? "multi" : "csv"),
name: paramName,
};
if (queryNameOrOptions) {
if (queryNameOrOptions.kind === "String") {
options.name = queryNameOrOptions.value;
} else if (queryNameOrOptions.kind === "Model") {
const name = queryNameOrOptions.properties.get("name")?.type;
if (name?.kind === "String") {
options.name = name.value;
}
const format = queryNameOrOptions.properties.get("format")?.type;
if (format?.kind === "String") {
options.format = format.value as any; // That value should have already been validated by the TypeSpec dec
}
} else {
return;
}
}
if (
entity.type.kind === "Model" &&
isArrayModelType(context.program, entity.type) &&
options.format === undefined
) {
reportDiagnostic(context.program, {
code: "query-format-required",
target: context.decoratorTarget,
});
}
context.program.stateMap(HttpStateKeys.query).set(entity, options);
};
@ -173,11 +165,20 @@ export function isQueryParam(program: Program, entity: Type) {
export const $path: PathDecorator = (
context: DecoratorContext,
entity: ModelProperty,
paramName?: string
paramNameOrOptions?: string | PathOptions
) => {
const paramName =
typeof paramNameOrOptions === "string"
? paramNameOrOptions
: (paramNameOrOptions?.name ?? entity.name);
const userOptions: PathOptions = typeof paramNameOrOptions === "object" ? paramNameOrOptions : {};
const options: PathParameterOptions = {
type: "path",
name: paramName ?? entity.name,
explode: userOptions.explode ?? false,
allowReserved: userOptions.allowReserved ?? false,
style: userOptions.style ?? "simple",
name: paramName,
};
context.program.stateMap(HttpStateKeys.path).set(entity, options);
};

Просмотреть файл

@ -74,7 +74,9 @@ export interface BodyPropertyProperty extends HttpPropertyBase {
}
export interface GetHttpPropertyOptions {
isImplicitPathParam?: (param: ModelProperty) => boolean;
implicitParameter?: (
param: ModelProperty
) => PathParameterOptions | QueryParameterOptions | undefined;
}
/**
* Find the type of a property in a model
@ -102,14 +104,57 @@ function getHttpProperty(
statusCode: isStatusCode(program, property),
};
const defined = Object.entries(annotations).filter((x) => !!x[1]);
const implicit = options.implicitParameter?.(property);
if (implicit && defined.length > 0) {
if (implicit.type === "path" && annotations.path) {
if (
annotations.path.explode ||
annotations.path.style !== "simple" ||
annotations.path.allowReserved
) {
diagnostics.push(
createDiagnostic({
code: "use-uri-template",
format: {
param: property.name,
},
target: property,
})
);
}
} else if (implicit.type === "query" && annotations.query) {
if (annotations.query.explode) {
diagnostics.push(
createDiagnostic({
code: "use-uri-template",
format: {
param: property.name,
},
target: property,
})
);
}
} else {
diagnostics.push(
createDiagnostic({
code: "incompatible-uri-param",
format: {
param: property.name,
uriKind: implicit.type,
annotationKind: defined[0][0],
},
target: property,
})
);
}
}
if (defined.length === 0) {
if (options.isImplicitPathParam && options.isImplicitPathParam(property)) {
if (implicit) {
return createResult({
kind: "path",
options: {
name: property.name,
type: "path",
},
kind: implicit.type,
options: implicit as any,
property,
});
}
return createResult({ kind: "bodyProperty" });

Просмотреть файл

@ -8,7 +8,7 @@ export * from "./decorators.js";
export type { HttpProperty } from "./http-property.js";
export * from "./metadata.js";
export * from "./operations.js";
export * from "./parameters.js";
export { getOperationParameters } from "./parameters.js";
export {
HttpPart,
getHttpFileModel,

Просмотреть файл

@ -9,12 +9,25 @@ export const $lib = createTypeSpecLibrary({
default: paramMessage`HTTP verb already applied to ${"entityName"}`,
},
},
"missing-path-param": {
"missing-uri-param": {
severity: "error",
messages: {
default: paramMessage`Route reference parameter '${"param"}' but wasn't found in operation parameters`,
},
},
"incompatible-uri-param": {
severity: "error",
messages: {
default: paramMessage`Parameter '${"param"}' is defined in the uri as a ${"uriKind"} but is annotated as a ${"annotationKind"}.`,
},
},
"use-uri-template": {
severity: "error",
messages: {
default: paramMessage`Parameter '${"param"}' is already defined in the uri template. Explode, style and allowReserved property must be defined in the uri template as described by RFC 6570.`,
},
},
"optional-path-param": {
severity: "error",
messages: {
@ -153,12 +166,6 @@ export const $lib = createTypeSpecLibrary({
default: `A format must be specified for @header when type is an array. e.g. @header({format: "csv"})`,
},
},
"query-format-required": {
severity: "error",
messages: {
default: `A format must be specified for @query when type is an array. e.g. @query({format: "multi"})`,
},
},
},
state: {
authentication: { description: "State for the @auth decorator" },
@ -187,6 +194,6 @@ export const $lib = createTypeSpecLibrary({
file: { description: "State for the @Private.file decorator" },
httpPart: { description: "State for the @Private.httpPart decorator" },
},
} as const);
});
export const { reportDiagnostic, createDiagnostic, stateKeys: HttpStateKeys } = $lib;

Просмотреть файл

@ -221,7 +221,8 @@ function getHttpOperationInternal(
const httpOperation: HttpOperation = {
path: route.path,
pathSegments: route.pathSegments,
uriTemplate: route.uriTemplate,
pathSegments: [],
verb: route.parameters.verb,
container: operation.interface ?? operation.namespace ?? program.getGlobalNamespaceType(),
parameters: route.parameters,

Просмотреть файл

@ -15,13 +15,16 @@ import {
HttpOperationParameters,
HttpVerb,
OperationParameterOptions,
PathParameterOptions,
QueryParameterOptions,
} from "./types.js";
import { parseUriTemplate } from "./uri-template.js";
export function getOperationParameters(
program: Program,
operation: Operation,
partialUriTemplate: string,
overloadBase?: HttpOperation,
knownPathParamNames: string[] = [],
options: OperationParameterOptions = {}
): [HttpOperationParameters, readonly Diagnostic[]] {
const verb =
@ -30,36 +33,75 @@ export function getOperationParameters(
overloadBase?.verb;
if (verb) {
return getOperationParametersForVerb(program, operation, verb, knownPathParamNames);
return getOperationParametersForVerb(program, operation, verb, partialUriTemplate);
}
// If no verb is explicitly specified, it is POST if there is a body and
// GET otherwise. Theoretically, it is possible to use @visibility
// strangely such that there is no body if the verb is POST and there is a
// body if the verb is GET. In that rare case, GET is chosen arbitrarily.
const post = getOperationParametersForVerb(program, operation, "post", knownPathParamNames);
const post = getOperationParametersForVerb(program, operation, "post", partialUriTemplate);
return post[0].body
? post
: getOperationParametersForVerb(program, operation, "get", knownPathParamNames);
: getOperationParametersForVerb(program, operation, "get", partialUriTemplate);
}
const operatorToStyle = {
";": "matrix",
"#": "fragment",
".": "label",
"/": "path",
} as const;
function getOperationParametersForVerb(
program: Program,
operation: Operation,
verb: HttpVerb,
knownPathParamNames: string[]
partialUriTemplate: string
): [HttpOperationParameters, readonly Diagnostic[]] {
const diagnostics = createDiagnosticCollector();
const visibility = resolveRequestVisibility(program, operation, verb);
function isImplicitPathParam(param: ModelProperty) {
const isTopLevel = param.model === operation.parameters;
return isTopLevel && knownPathParamNames.includes(param.name);
}
const parsedUriTemplate = parseUriTemplate(partialUriTemplate);
const parameters: HttpOperationParameter[] = [];
const { body: resolvedBody, metadata } = diagnostics.pipe(
resolveHttpPayload(program, operation.parameters, visibility, "request", {
isImplicitPathParam,
implicitParameter: (
param: ModelProperty
): QueryParameterOptions | PathParameterOptions | undefined => {
const isTopLevel = param.model === operation.parameters;
const uriParam =
isTopLevel && parsedUriTemplate.parameters.find((x) => x.name === param.name);
if (!uriParam) {
return undefined;
}
const explode = uriParam.modifier?.type === "explode";
if (uriParam.operator === "?" || uriParam.operator === "&") {
return {
type: "query",
name: uriParam.name,
explode,
};
} else if (uriParam.operator === "+") {
return {
type: "path",
name: uriParam.name,
explode,
allowReserved: true,
style: "simple",
};
} else {
return {
type: "path",
name: uriParam.name,
explode,
allowReserved: false,
style: (uriParam.operator && operatorToStyle[uriParam.operator]) ?? "simple",
};
}
},
})
);

Просмотреть файл

@ -1,4 +1,5 @@
import {
createDiagnosticCollector,
DecoratorContext,
DiagnosticResult,
Interface,
@ -6,21 +7,22 @@ import {
Operation,
Program,
Type,
createDiagnosticCollector,
validateDecoratorTarget,
} from "@typespec/compiler";
import { HttpStateKeys, createDiagnostic, reportDiagnostic } from "./lib.js";
import { createDiagnostic, HttpStateKeys, reportDiagnostic } from "./lib.js";
import { getOperationParameters } from "./parameters.js";
import {
HttpOperation,
HttpOperationParameter,
HttpOperationParameters,
PathParameterOptions,
RouteOptions,
RoutePath,
RouteProducer,
RouteProducerResult,
RouteResolutionOptions,
} from "./types.js";
import { extractParamsFromPath } from "./utils.js";
import { parseUriTemplate, UriTemplate } from "./uri-template.js";
// The set of allowed segment separator characters
const AllowedSegmentSeparators = ["/", ":"];
@ -37,7 +39,7 @@ function normalizeFragment(fragment: string, trimLast = false) {
return fragment;
}
function joinPathSegments(rest: string[]) {
export function joinPathSegments(rest: string[]) {
let current = "";
for (const [index, segment] of rest.entries()) {
current += normalizeFragment(segment, index < rest.length - 1);
@ -59,42 +61,60 @@ export function resolvePathAndParameters(
overloadBase: HttpOperation | undefined,
options: RouteResolutionOptions
): DiagnosticResult<{
readonly uriTemplate: string;
path: string;
pathSegments: string[];
parameters: HttpOperationParameters;
}> {
const diagnostics = createDiagnosticCollector();
const { segments, parameters } = diagnostics.pipe(
getRouteSegments(program, operation, overloadBase, options)
const { uriTemplate, parameters } = diagnostics.pipe(
getUriTemplateAndParameters(program, operation, overloadBase, options)
);
const parsedUriTemplate = parseUriTemplate(uriTemplate);
// Pull out path parameters to verify what's in the path string
const paramByName = new Set(
parameters.parameters.filter(({ type }) => type === "path").map((x) => x.name)
parameters.parameters
.filter(({ type }) => type === "path" || type === "query")
.map((x) => x.name)
);
// Ensure that all of the parameters defined in the route are accounted for in
// the operation parameters
const routeParams = segments.flatMap(extractParamsFromPath);
for (const routeParam of routeParams) {
if (!paramByName.has(routeParam)) {
for (const routeParam of parsedUriTemplate.parameters) {
if (!paramByName.has(routeParam.name)) {
diagnostics.add(
createDiagnostic({
code: "missing-path-param",
format: { param: routeParam },
code: "missing-uri-param",
format: { param: routeParam.name },
target: operation,
})
);
}
}
const path = produceLegacyPathFromUriTemplate(parsedUriTemplate);
return diagnostics.wrap({
path: buildPath(segments),
pathSegments: segments,
uriTemplate,
path,
parameters,
});
}
function produceLegacyPathFromUriTemplate(uriTemplate: UriTemplate) {
let result = "";
for (const segment of uriTemplate.segments ?? []) {
if (typeof segment === "string") {
result += segment;
} else if (segment.operator !== "?" && segment.operator !== "&") {
result += `{${segment.name}}`;
}
}
return result;
}
function collectSegmentsAndOptions(
program: Program,
source: Interface | Namespace | undefined
@ -110,27 +130,27 @@ function collectSegmentsAndOptions(
return [[...parentSegments, ...(route ? [route] : [])], { ...parentOptions, ...options }];
}
function getRouteSegments(
function getUriTemplateAndParameters(
program: Program,
operation: Operation,
overloadBase: HttpOperation | undefined,
options: RouteResolutionOptions
): DiagnosticResult<RouteProducerResult> {
const diagnostics = createDiagnosticCollector();
const [parentSegments, parentOptions] = collectSegmentsAndOptions(
program,
operation.interface ?? operation.namespace
);
const routeProducer = getRouteProducer(program, operation) ?? DefaultRouteProducer;
const result = diagnostics.pipe(
routeProducer(program, operation, parentSegments, overloadBase, {
...parentOptions,
...options,
})
);
const [result, diagnostics] = routeProducer(program, operation, parentSegments, overloadBase, {
...parentOptions,
...options,
});
return diagnostics.wrap(result);
return [
{ uriTemplate: buildPath([result.uriTemplate]), parameters: result.parameters },
diagnostics,
];
}
/**
@ -162,37 +182,61 @@ export function DefaultRouteProducer(
): DiagnosticResult<RouteProducerResult> {
const diagnostics = createDiagnosticCollector();
const routePath = getRoutePath(program, operation)?.path;
const segments =
const uriTemplate =
!routePath && overloadBase
? overloadBase.pathSegments
: [...parentSegments, ...(routePath ? [routePath] : [])];
const routeParams = segments.flatMap(extractParamsFromPath);
? overloadBase.uriTemplate
: joinPathSegments([...parentSegments, ...(routePath ? [routePath] : [])]);
const parsedUriTemplate = parseUriTemplate(uriTemplate);
const parameters: HttpOperationParameters = diagnostics.pipe(
getOperationParameters(program, operation, overloadBase, routeParams, options.paramOptions)
getOperationParameters(program, operation, uriTemplate, overloadBase, options.paramOptions)
);
// Pull out path parameters to verify what's in the path string
const unreferencedPathParamNames = new Set(
parameters.parameters.filter(({ type }) => type === "path").map((x) => x.name)
const unreferencedPathParamNames = new Map(
parameters.parameters
.filter(({ type }) => type === "path" || type === "query")
.map((x) => [x.name, x])
);
// Compile the list of all route params that aren't represented in the route
for (const routeParam of routeParams) {
unreferencedPathParamNames.delete(routeParam);
}
// Add any remaining declared path params
for (const paramName of unreferencedPathParamNames) {
segments.push(`{${paramName}}`);
for (const uriParam of parsedUriTemplate.parameters) {
unreferencedPathParamNames.delete(uriParam.name);
}
const resolvedUriTemplate = addOperationTemplateToUriTemplate(uriTemplate, [
...unreferencedPathParamNames.values(),
]);
return diagnostics.wrap({
segments,
uriTemplate: resolvedUriTemplate,
parameters,
});
}
const styleToOperator: Record<PathParameterOptions["style"], string> = {
matrix: ";",
label: ".",
simple: "",
path: "/",
fragment: "#",
};
function addOperationTemplateToUriTemplate(uriTemplate: string, params: HttpOperationParameter[]) {
const pathParams = params
.filter((x) => x.type === "path")
.map((param) => {
const operator = param.allowReserved ? "+" : styleToOperator[param.style];
return `{${operator}${param.name}${param.explode ? "*" : ""}}`;
});
const queryParams = params.filter((x) => x.type === "query");
const pathPart = joinPathSegments([uriTemplate, ...pathParams]);
return (
pathPart + (queryParams.length > 0 ? `{?${queryParams.map((x) => x.name).join(",")}}` : "")
);
}
export function setRouteProducer(
program: Program,
operation: Operation,

Просмотреть файл

@ -10,6 +10,7 @@ import {
Tuple,
Type,
} from "@typespec/compiler";
import { PathOptions, QueryOptions } from "../generated-defs/TypeSpec.Http.js";
import { HeaderProperty, HttpProperty } from "./http-property.js";
/**
@ -277,7 +278,7 @@ export interface RouteResolutionOptions extends RouteOptions {
}
export interface RouteProducerResult {
segments: string[];
uriTemplate: string;
parameters: HttpOperationParameters;
}
@ -299,26 +300,30 @@ export interface HeaderFieldOptions {
format?: "csv" | "multi" | "ssv" | "tsv" | "pipes" | "simple" | "form";
}
export interface QueryParameterOptions {
export interface QueryParameterOptions extends Required<Omit<QueryOptions, "format">> {
type: "query";
name: string;
/**
* The string format of the array. "csv" and "simple" are used interchangeably, as are
* "multi" and "form".
* @deprecated use explode and `@encode` decorator instead.
*/
format?: "multi" | "csv" | "ssv" | "tsv" | "pipes" | "simple" | "form";
format?: "csv" | "multi" | "ssv" | "tsv" | "pipes" | "simple" | "form";
}
export interface PathParameterOptions {
export interface PathParameterOptions extends Required<PathOptions> {
type: "path";
name: string;
}
export type HttpOperationParameter = (
| HeaderFieldOptions
| QueryParameterOptions
| PathParameterOptions
) & {
export type HttpOperationParameter =
| HttpOperationHeaderParameter
| HttpOperationQueryParameter
| HttpOperationPathParameter;
export type HttpOperationHeaderParameter = HeaderFieldOptions & {
param: ModelProperty;
};
export type HttpOperationQueryParameter = QueryParameterOptions & {
param: ModelProperty;
};
export type HttpOperationPathParameter = PathParameterOptions & {
param: ModelProperty;
};
@ -362,12 +367,21 @@ export interface HttpService {
export interface HttpOperation {
/**
* Route path
* The fully resolved uri template as defined by http://tools.ietf.org/html/rfc6570.
* @example "/foo/{bar}/baz{?qux}"
* @example "/foo/{+path}"
*/
readonly uriTemplate: string;
/**
* Route path.
* Not recommended use {@link uriTemplate} instead. This will not work for complex cases like not-escaping reserved chars.
*/
path: string;
/**
* Path segments
* @deprecated use {@link uriTemplate} instead
*/
pathSegments: string[];

Просмотреть файл

@ -0,0 +1,54 @@
const operators = ["+", "#", ".", "/", ";", "?", "&"] as const;
type Operator = (typeof operators)[number];
export interface UriTemplateParameter {
readonly name: string;
readonly operator?: Operator;
readonly modifier?: { type: "explode" } | { type: "prefix"; value: number };
}
export interface UriTemplate {
readonly segments?: (string | UriTemplateParameter)[];
readonly parameters: UriTemplateParameter[];
}
const uriTemplateRegex = /\{([^{}]+)\}|([^{}]+)/g;
const expressionRegex = /([^:*]*)(?::(\d+)|(\*))?/;
/**
* Parse a URI template according to [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3)
*/
export function parseUriTemplate(template: string): UriTemplate {
const parameters: UriTemplateParameter[] = [];
const segments: (string | UriTemplateParameter)[] = [];
const matches = template.matchAll(uriTemplateRegex);
for (let [_, expression, literal] of matches) {
if (expression) {
let operator: Operator | undefined;
if (operators.includes(expression[0] as any)) {
operator = expression[0] as any;
expression = expression.slice(1);
}
const items = expression.split(",");
for (const item of items) {
const match = item.match(expressionRegex)!;
const name = match[1];
const parameter: UriTemplateParameter = {
name: name,
operator,
modifier: match[3]
? { type: "explode" }
: match[2]
? { type: "prefix", value: Number(match[2]) }
: undefined,
};
parameters.push(parameter);
segments.push(parameter);
}
} else {
segments.push(literal);
}
}
return { segments, parameters };
}

Просмотреть файл

@ -157,8 +157,8 @@ describe("http: decorators", () => {
it("emit diagnostics when query name is not a string or of type QueryOptions", async () => {
const diagnostics = await runner.diagnose(`
op test(@query(123) MyQuery: string): string;
op test2(@query({name: 123}) MyQuery: string): string;
op test3(@query({format: "invalid"}) MyQuery: string): string;
op test2(@query(#{name: 123}) MyQuery: string): string;
op test3(@query(#{format: "invalid"}) MyQuery: string): string;
`);
expectDiagnostics(diagnostics, [
@ -174,17 +174,6 @@ describe("http: decorators", () => {
]);
});
it("emit diagnostics when query is not specifing format but is an array", async () => {
const diagnostics = await runner.diagnose(`
op test(@query select: string[]): string;
`);
expectDiagnostics(diagnostics, {
code: "@typespec/http/query-format-required",
message: `A format must be specified for @query when type is an array. e.g. @query({format: "multi"})`,
});
});
it("generate query name from property name", async () => {
const { select } = await runner.compile(`
op test(@test @query select: string): string;
@ -202,15 +191,43 @@ describe("http: decorators", () => {
strictEqual(getQueryParamName(runner.program, select), "$select");
});
describe("change format for array value", () => {
["csv", "tsv", "ssv", "simple", "form", "pipes"].forEach((format) => {
it("specify explode: true", async () => {
const { selects } = await runner.compile(`
op test(@test @query(#{ explode: true }) selects: string[]): string;
`);
expect(getQueryParamOptions(runner.program, selects)).toEqual({
type: "query",
name: "selects",
format: "multi",
explode: true,
});
});
describe("LEGACY: change format for array value", () => {
["csv", "tsv", "ssv", "simple", "pipes"].forEach((format) => {
it(`set query format to "${format}"`, async () => {
const { selects } = await runner.compile(`
op test(@test @query({name: "$select", format: "${format}"}) selects: string[]): string;
#suppress "deprecated" "Test"
op test(@test @query(#{name: "$select", format: "${format}"}) selects: string[]): string;
`);
deepStrictEqual(getQueryParamOptions(runner.program, selects), {
type: "query",
name: "$select",
explode: false,
format,
});
});
});
["form"].forEach((format) => {
it(`set query format to "${format}"`, async () => {
const { selects } = await runner.compile(`
#suppress "deprecated" "Test"
op test(@test @query(#{name: "$select", format: "${format}"}) selects: string[]): string;
`);
deepStrictEqual(getQueryParamOptions(runner.program, selects), {
type: "query",
name: "$select",
explode: true,
format,
});
});
@ -372,6 +389,9 @@ describe("http: decorators", () => {
deepStrictEqual(getPathParamOptions(runner.program, select), {
type: "path",
name: "$select",
allowReserved: false,
explode: false,
style: "simple",
});
strictEqual(getPathParamName(runner.program, select), "$select");
});

Просмотреть файл

@ -1,8 +1,9 @@
import { Operation } from "@typespec/compiler";
import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing";
import { deepStrictEqual, strictEqual } from "assert";
import { describe, it } from "vitest";
import { HttpOperation, getRoutePath } from "../src/index.js";
import { deepStrictEqual, ok, strictEqual } from "assert";
import { describe, expect, it } from "vitest";
import { PathOptions } from "../generated-defs/TypeSpec.Http.js";
import { HttpOperation, HttpOperationParameter, getRoutePath } from "../src/index.js";
import {
compileOperations,
createHttpTestRunner,
@ -171,7 +172,7 @@ describe("http: routes", () => {
`@route("/foo/{myParam}/") op test(@path other: string): void;`
);
expectDiagnostics(diagnostics, {
code: "@typespec/http/missing-path-param",
code: "@typespec/http/missing-uri-param",
message: "Route reference parameter 'myParam' but wasn't found in operation parameters",
});
});
@ -508,3 +509,152 @@ describe("http: routes", () => {
});
});
});
describe("uri template", () => {
async function getOp(code: string) {
const ops = await getOperations(code);
return ops[0];
}
describe("extract implicit parameters", () => {
async function getParameter(code: string, name: string) {
const op = await getOp(code);
const param = op.parameters.parameters.find((x) => x.name === name);
ok(param);
expect(param.name).toEqual(name);
return param;
}
function expectPathParameter(param: HttpOperationParameter, expected: PathOptions) {
strictEqual(param.type, "path");
const { style, explode, allowReserved } = param;
expect({ style, explode, allowReserved }).toEqual(expected);
}
it("extract simple path parameter", async () => {
const param = await getParameter(`@route("/bar/{foo}") op foo(foo: string): void;`, "foo");
expectPathParameter(param, { style: "simple", allowReserved: false, explode: false });
});
it("+ operator map to allowReserved", async () => {
const param = await getParameter(`@route("/bar/{+foo}") op foo(foo: string): void;`, "foo");
expectPathParameter(param, { style: "simple", allowReserved: true, explode: false });
});
it.each([
[";", "matrix"],
["#", "fragment"],
[".", "label"],
["/", "path"],
] as const)("%s map to style: %s", async (operator, style) => {
const param = await getParameter(
`@route("/bar/{${operator}foo}") op foo(foo: string): void;`,
"foo"
);
expectPathParameter(param, { style, allowReserved: false, explode: false });
});
function expectQueryParameter(param: HttpOperationParameter, expected: PathOptions) {
strictEqual(param.type, "query");
const { explode } = param;
expect({ explode }).toEqual(expected);
}
it("extract simple query parameter", async () => {
const param = await getParameter(`@route("/bar{?foo}") op foo(foo: string): void;`, "foo");
expectQueryParameter(param, { explode: false });
});
it("extract explode query parameter", async () => {
const param = await getParameter(`@route("/bar{?foo*}") op foo(foo: string): void;`, "foo");
expectQueryParameter(param, { explode: true });
});
it("extract simple query continuation parameter", async () => {
const param = await getParameter(
`@route("/bar?fixed=yes{&foo}") op foo(foo: string): void;`,
"foo"
);
expectQueryParameter(param, { explode: false });
});
});
describe("build uriTemplate from parameter", () => {
it.each([
["@path one: string", "/foo/{one}"],
["@path(#{allowReserved: true}) one: string", "/foo/{+one}"],
["@path(#{explode: true}) one: string", "/foo/{one*}"],
[`@path(#{style: "matrix"}) one: string`, "/foo/{;one}"],
[`@path(#{style: "label"}) one: string`, "/foo/{.one}"],
[`@path(#{style: "fragment"}) one: string`, "/foo/{#one}"],
[`@path(#{style: "path"}) one: string`, "/foo/{/one}"],
["@path(#{allowReserved: true, explode: true}) one: string", "/foo/{+one*}"],
["@query one: string", "/foo{?one}"],
])("%s -> %s", async (param, expectedUri) => {
const op = await getOp(`@route("/foo") op foo(${param}): void;`);
expect(op.uriTemplate).toEqual(expectedUri);
});
});
it("emit diagnostic when annotating a path parameter with @query", async () => {
const diagnostics = await diagnoseOperations(
`@route("/bar/{foo}") op foo(@query foo: string): void;`
);
expectDiagnostics(diagnostics, {
code: "@typespec/http/incompatible-uri-param",
message: "Parameter 'foo' is defined in the uri as a path but is annotated as a query.",
});
});
it("emit diagnostic when annotating a query parameter with @path", async () => {
const diagnostics = await diagnoseOperations(
`@route("/bar/{?foo}") op foo(@path foo: string): void;`
);
expectDiagnostics(diagnostics, {
code: "@typespec/http/incompatible-uri-param",
message: "Parameter 'foo' is defined in the uri as a query but is annotated as a path.",
});
});
it("emit diagnostic when annotating a query continuation parameter with @path", async () => {
const diagnostics = await diagnoseOperations(
`@route("/bar/?bar=def{&foo}") op foo(@path foo: string): void;`
);
expectDiagnostics(diagnostics, {
code: "@typespec/http/incompatible-uri-param",
message: "Parameter 'foo' is defined in the uri as a query but is annotated as a path.",
});
});
describe("emit diagnostic if using any of the path options when parameter is already defined in the uri template", () => {
it.each([
"#{ allowReserved: true }",
"#{ explode: true }",
`#{ style: "label" }`,
`#{ style: "matrix" }`,
`#{ style: "fragment" }`,
`#{ style: "path" }`,
])("%s", async (options) => {
const diagnostics = await diagnoseOperations(
`@route("/bar/{foo}") op foo(@path(${options}) foo: string): void;`
);
expectDiagnostics(diagnostics, {
code: "@typespec/http/use-uri-template",
message:
"Parameter 'foo' is already defined in the uri template. Explode, style and allowReserved property must be defined in the uri template as described by RFC 6570.",
});
});
});
describe("emit diagnostic if using any of the query options when parameter is already defined in the uri template", () => {
it.each(["#{ explode: true }"])("%s", async (options) => {
const diagnostics = await diagnoseOperations(
`@route("/bar{?foo}") op foo(@query(${options}) foo: string): void;`
);
expectDiagnostics(diagnostics, {
code: "@typespec/http/use-uri-template",
message:
"Parameter 'foo' is already defined in the uri template. Explode, style and allowReserved property must be defined in the uri template as described by RFC 6570.",
});
});
});
});

Просмотреть файл

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { parseUriTemplate } from "../src/uri-template.js";
it("no parameter", () => {
expect(parseUriTemplate("/foo").parameters).toEqual([]);
});
it("simple parameters", () => {
expect(parseUriTemplate("/foo/{one}/bar/baz/{two}").parameters).toEqual([
{ name: "one" },
{ name: "two" },
]);
});
describe("operators", () => {
it.each(["+", "#", ".", "/", ";", "?", "&"])("%s", (operator) => {
expect(parseUriTemplate(`/foo/{${operator}one}`).parameters).toEqual([
{ name: "one", operator },
]);
});
});
it("define explode parameter", () => {
expect(parseUriTemplate("/foo/{one*}").parameters).toEqual([
{ name: "one", modifier: { type: "explode" } },
]);
});
it("define prefix parameter", () => {
expect(parseUriTemplate("/foo/{one:3}").parameters).toEqual([
{ name: "one", modifier: { type: "prefix", value: 3 } },
]);
});

Просмотреть файл

@ -170,6 +170,18 @@ export const libDef = {
default: paramMessage`Collection format '${"value"}' is not supported in OpenAPI3 ${"paramType"} parameters. Defaulting to type 'string'.`,
},
},
"invalid-style": {
severity: "warning",
messages: {
default: paramMessage`Style '${"style"}' is not supported in OpenAPI3 ${"paramType"} parameters. Defaulting to style 'simple'.`,
},
},
"path-reserved-expansion": {
severity: "warning",
messages: {
default: `Reserved expansion of path parameter with '+' operator #{allowReserved: true} is not supported in OpenAPI3.`,
},
},
"resource-namespace": {
severity: "error",
messages: {

Просмотреть файл

@ -68,7 +68,6 @@ import {
isOrExtendsHttpFile,
isOverloadSameEndpoint,
MetadataInfo,
QueryParameterOptions,
reportIfNoRoutes,
resolveAuthentication,
resolveRequestVisibility,
@ -1221,13 +1220,13 @@ function createOAPIEmitter(
...getOpenAPIParameterBase(parameter.param, visibility),
} as any;
const format = mapParameterFormat(parameter);
if (format === undefined) {
const attributes = getParameterAttributes(parameter);
if (attributes === undefined) {
param.schema = {
type: "string",
};
} else {
Object.assign(param, format);
Object.assign(param, attributes);
}
return param;
@ -1403,16 +1402,87 @@ function createOAPIEmitter(
return target;
}
function mapParameterFormat(
function getParameterAttributes(
parameter: HttpOperationParameter
): { style?: string; explode?: boolean } | undefined {
switch (parameter.type) {
case "header":
return mapHeaderParameterFormat(parameter);
case "query":
return mapQueryParameterFormat(parameter);
return getQueryParameterAttributes(parameter);
case "path":
return {};
return getPathParameterAttributes(parameter);
}
}
function getPathParameterAttributes(parameter: HttpOperationParameter & { type: "path" }) {
if (parameter.allowReserved) {
diagnostics.add(
createDiagnostic({
code: "path-reserved-expansion",
target: parameter.param,
})
);
}
const attributes: { style?: string; explode?: boolean } = {};
if (parameter.explode) {
attributes.explode = true;
}
switch (parameter.style) {
case "label":
attributes.style = "label";
break;
case "matrix":
attributes.style = "matrix";
break;
case "simple":
break;
default:
diagnostics.add(
createDiagnostic({
code: "invalid-style",
format: { style: parameter.style, paramType: "path" },
target: parameter.param,
})
);
}
return attributes;
}
function getQueryParameterAttributes(parameter: HttpOperationParameter & { type: "query" }) {
const attributes: { style?: string; explode?: boolean } = {};
if (parameter.explode) {
attributes.explode = true;
}
switch (parameter.format) {
case "ssv":
return { style: "spaceDelimited", explode: false };
case "pipes":
return { style: "pipeDelimited", explode: false };
case undefined:
case "csv":
case "simple":
case "multi":
case "form":
return attributes;
default:
diagnostics.add(
createDiagnostic({
code: "invalid-format",
format: {
paramType: "query",
value: parameter.format,
},
target: parameter.param,
})
);
return undefined;
}
}
@ -1441,39 +1511,6 @@ function createOAPIEmitter(
return undefined;
}
}
function mapQueryParameterFormat(
parameter: QueryParameterOptions & {
param: ModelProperty;
}
): { style?: string; explode?: boolean } | undefined {
switch (parameter.format) {
case undefined:
return {};
case "csv":
case "simple":
return { style: "form", explode: false };
case "multi":
case "form":
return { style: "form", explode: true };
case "ssv":
return { style: "spaceDelimited", explode: false };
case "pipes":
return { style: "pipeDelimited", explode: false };
default:
diagnostics.add(
createDiagnostic({
code: "invalid-format",
format: {
paramType: "query",
value: parameter.format,
},
target: parameter.param,
})
);
return undefined;
}
}
function emitParameters() {
for (const [property, param] of params) {

Просмотреть файл

@ -1,33 +1,56 @@
import { expectDiagnostics } from "@typespec/compiler/testing";
import { deepStrictEqual, ok, strictEqual } from "assert";
import { describe, expect, it } from "vitest";
import { OpenAPI3PathParameter, OpenAPI3QueryParameter } from "../src/types.js";
import { diagnoseOpenApiFor, openApiFor } from "./test-host.js";
describe("openapi3: parameters", () => {
describe("query parameters", () => {
async function getQueryParam(code: string): Promise<OpenAPI3QueryParameter> {
const res = await openApiFor(code);
const param = res.paths[`/`].get.parameters[0];
strictEqual(param.in, "query");
return param;
}
it("create a query param", async () => {
const res = await openApiFor(
`
op test(@query arg1: string): void;
const param = await getQueryParam(
`op test(@query myParam: string): void;
`
);
strictEqual(res.paths["/"].get.parameters[0].in, "query");
strictEqual(res.paths["/"].get.parameters[0].name, "arg1");
deepStrictEqual(res.paths["/"].get.parameters[0].schema, { type: "string" });
strictEqual(param.name, "myParam");
deepStrictEqual(param.schema, { type: "string" });
});
it("create a query param with a different name", async () => {
const res = await openApiFor(
const param = await getQueryParam(
`
op test(@query("$select") select: string): void;
`
);
strictEqual(res.paths["/"].get.parameters[0].in, "query");
strictEqual(res.paths["/"].get.parameters[0].name, "$select");
strictEqual(param.in, "query");
strictEqual(param.name, "$select");
});
it("create a query param of array type", async () => {
describe("set explode: true", () => {
it("with option", async () => {
const param = await getQueryParam(`op test(@query(#{explode: true}) myParam: string): void;`);
expect(param).toMatchObject({
explode: true,
});
});
it("with uri template", async () => {
const param = await getQueryParam(`@route("{?myParam*}") op test(myParam: string): void;`);
expect(param).toMatchObject({
explode: true,
});
});
});
it("LEGACY: specify the format", async () => {
const res = await openApiFor(
`
#suppress "deprecated" "test"
op test(
@query({name: "$multi", format: "multi"}) multis: string[],
@query({name: "$csv", format: "csv"}) csvs: string[],
@ -42,7 +65,6 @@ describe("openapi3: parameters", () => {
deepStrictEqual(params[0], {
in: "query",
name: "$multi",
style: "form",
required: true,
explode: true,
schema: {
@ -55,8 +77,6 @@ describe("openapi3: parameters", () => {
deepStrictEqual(params[1], {
in: "query",
name: "$csv",
style: "form",
explode: false,
schema: {
type: "array",
items: {
@ -417,16 +437,136 @@ describe("openapi3: parameters", () => {
ok(res.paths["/"].post.requestBody.content["application/json"]);
});
});
});
describe("path parameters", () => {
it("figure out the route parameter from the name of the param", async () => {
const res = await openApiFor(`op test(@path myParam: string): void;`);
expect(res.paths).toHaveProperty("/{myParam}");
describe("path parameters", () => {
async function getPathParam(code: string, name = "myParam"): Promise<OpenAPI3PathParameter> {
const res = await openApiFor(code);
return res.paths[`/{${name}}`].get.parameters[0];
}
it("figure out the route parameter from the name of the param", async () => {
const res = await openApiFor(`op test(@path myParam: string): void;`);
expect(res.paths).toHaveProperty("/{myParam}");
});
it("uses explicit name provided from @path", async () => {
const res = await openApiFor(`op test(@path("my-custom-path") myParam: string): void;`);
expect(res.paths).toHaveProperty("/{my-custom-path}");
});
describe("set explode: true", () => {
it("with option", async () => {
const param = await getPathParam(`op test(@path(#{explode: true}) myParam: string[]): void;`);
expect(param).toMatchObject({
explode: true,
schema: {
type: "array",
items: { type: "string" },
},
});
});
it("with uri template", async () => {
const param = await getPathParam(`@route("{myParam*}") op test(myParam: string[]): void;`);
expect(param).toMatchObject({
explode: true,
schema: {
type: "array",
items: { type: "string" },
},
});
});
});
describe("set style: simple", () => {
it("with option", async () => {
const param = await getPathParam(`op test(@path(#{style: "simple"}) myParam: string): void;`);
expect(param).not.toHaveProperty("style");
});
it("uses explicit name provided from @path", async () => {
const res = await openApiFor(`op test(@path("my-custom-path") myParam: string): void;`);
expect(res.paths).toHaveProperty("/{my-custom-path}");
it("with uri template", async () => {
const param = await getPathParam(`@route("{myParam}") op test(myParam: string): void;`);
expect(param).not.toHaveProperty("style");
});
});
describe("set style: label", () => {
it("with option", async () => {
const param = await getPathParam(`op test(@path(#{style: "label"}) myParam: string): void;`);
expect(param).toMatchObject({
style: "label",
});
});
it("with uri template", async () => {
const param = await getPathParam(`@route("{.myParam}") op test(myParam: string): void;`);
expect(param).toMatchObject({
style: "label",
});
});
});
describe("set style: matrix", () => {
it("with option", async () => {
const param = await getPathParam(`op test(@path(#{style: "matrix"}) myParam: string): void;`);
expect(param).toMatchObject({
style: "matrix",
});
});
it("with uri template", async () => {
const param = await getPathParam(`@route("{;myParam}") op test(myParam: string): void;`);
expect(param).toMatchObject({
style: "matrix",
});
});
});
describe("emit diagnostic when using style: path", () => {
it("with option", async () => {
const diagnostics = await diagnoseOpenApiFor(
`op test(@path(#{style: "path"}) myParam: string): void;`
);
expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" });
});
it("with uri template", async () => {
const diagnostics = await diagnoseOpenApiFor(
`@route("{/myParam}") op test(myParam: string): void;`
);
expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" });
});
});
describe("emit diagnostic when using style: fragment", () => {
it("with option", async () => {
const diagnostics = await diagnoseOpenApiFor(
`op test(@path(#{style: "fragment"}) myParam: string): void;`
);
expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" });
});
it("with uri template", async () => {
const diagnostics = await diagnoseOpenApiFor(
`@route("{#myParam}") op test(myParam: string): void;`
);
expectDiagnostics(diagnostics, { code: "@typespec/openapi3/invalid-style" });
});
});
describe("emit diagnostic when using reserved expansion", () => {
it("with option", async () => {
const diagnostics = await diagnoseOpenApiFor(
`op test(@path(#{allowReserved: true}) myParam: string): void;`
);
expectDiagnostics(diagnostics, { code: "@typespec/openapi3/path-reserved-expansion" });
});
it("with uri template", async () => {
const diagnostics = await diagnoseOpenApiFor(
`@route("{+myParam}") op test(myParam: string): void;`
);
expectDiagnostics(diagnostics, { code: "@typespec/openapi3/path-reserved-expansion" });
});
});
});

Просмотреть файл

@ -21,6 +21,7 @@ import {
HttpOperationParameter,
HttpOperationParameters,
HttpVerb,
joinPathSegments,
RouteOptions,
RouteProducerResult,
setRouteProducer,
@ -114,7 +115,7 @@ function autoRouteProducer(
};
const parameters: HttpOperationParameters = diagnostics.pipe(
getOperationParameters(program, operation, undefined, [], paramOptions)
getOperationParameters(program, operation, "", undefined, paramOptions)
);
for (const httpParam of parameters.parameters) {
@ -155,7 +156,7 @@ function autoRouteProducer(
addActionFragment(program, operation, segments);
return diagnostics.wrap({
segments,
uriTemplate: joinPathSegments(segments),
parameters: {
...parameters,
parameters: filteredParameters,

Просмотреть файл

@ -1,6 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"references": [{ "path": "../compiler/tsconfig.json" }],
"references": [{ "path": "../compiler/tsconfig.json" }, { "path": "../http/tsconfig.json" }],
"compilerOptions": {
"outDir": "dist",
"rootDir": ".",

Просмотреть файл

@ -64,9 +64,7 @@ namespace Hello {
@get op read(
@path id: string,
@query({
format: "multi",
})
@query(#{ explode: true })
fieldMask: string[],
): ReadablePerson;
@post op create(@body person: WritablePerson): ReadablePerson;

Просмотреть файл

@ -86,7 +86,6 @@ paths:
type: array
items:
type: string
style: form
explode: true
responses:
'200':