diff --git a/.chronus/changes/uri-templates-2024-6-24-20-7-39.md b/.chronus/changes/uri-templates-2024-6-24-20-7-39.md new file mode 100644 index 0000000000..5b7e4db705 --- /dev/null +++ b/.chronus/changes/uri-templates-2024-6-24-20-7-39.md @@ -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; + ``` diff --git a/.chronus/changes/uri-templates-2024-6-24-21-37-52.md b/.chronus/changes/uri-templates-2024-6-24-21-37-52.md new file mode 100644 index 0000000000..ff5c021cf0 --- /dev/null +++ b/.chronus/changes/uri-templates-2024-6-24-21-37-52.md @@ -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 diff --git a/.chronus/changes/uri-templates-2024-6-25-9-3-39-2.md b/.chronus/changes/uri-templates-2024-6-25-9-3-39-2.md new file mode 100644 index 0000000000..fb4b55465d --- /dev/null +++ b/.chronus/changes/uri-templates-2024-6-25-9-3-39-2.md @@ -0,0 +1,7 @@ +--- +changeKind: deprecation +packages: + - "@typespec/http" +--- + +API deprecation: `HttpOperation#pathSegments` is deprecated. Use `HttpOperation#uriTemplate` instead. diff --git a/.chronus/changes/uri-templates-2024-6-25-9-3-39.md b/.chronus/changes/uri-templates-2024-6-25-9-3-39.md new file mode 100644 index 0000000000..88324b314b --- /dev/null +++ b/.chronus/changes/uri-templates-2024-6-25-9-3-39.md @@ -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[]; + ``` diff --git a/.chronus/changes/uri-templates-2024-7-6-16-39-59.md b/.chronus/changes/uri-templates-2024-7-6-16-39-59.md new file mode 100644 index 0000000000..35c362b3ba --- /dev/null +++ b/.chronus/changes/uri-templates-2024-7-6-16-39-59.md @@ -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 diff --git a/docs/libraries/http/reference/data-types.md b/docs/libraries/http/reference/data-types.md index 6456044669..549e8fc88d 100644 --- a/docs/libraries/http/reference/data-types.md +++ b/docs/libraries/http/reference/data-types.md @@ -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.
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.
- `simple`: No special encoding.
- `label`: Using `.` separator.
- `matrix`: `;` as separator.
- `fragment`: `#` as separator.
- `path`: `/` as separator. | +| allowReserved? | `boolean` | 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) | + ### `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.
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` \| | +| format? | `"multi" \| "csv" \| "ssv" \| "tsv" \| "simple" \| "form" \| "pipes"` | Determines the format of the array if type array is used.
**DEPRECATED**: use explode: true instead of `multi` or `@encode` | ### `Response` {#TypeSpec.Http.Response} diff --git a/docs/libraries/http/reference/decorators.md b/docs/libraries/http/reference/decorators.md index bddb422cf4..a55f429071 100644 --- a/docs/libraries/http/reference/decorators.md +++ b/docs/libraries/http/reference/decorators.md @@ -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} diff --git a/docs/libraries/http/reference/index.mdx b/docs/libraries/http/reference/index.mdx index d85da81df6..352ea8d952 100644 --- a/docs/libraries/http/reference/index.mdx +++ b/docs/libraries/http/reference/index.mdx @@ -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) diff --git a/docs/standard-library/built-in-data-types.md b/docs/standard-library/built-in-data-types.md index f330b84b24..ae1639782f 100644 --- a/docs/standard-library/built-in-data-types.md +++ b/docs/standard-library/built-in-data-types.md @@ -194,6 +194,19 @@ model UpdateableProperties #### 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 | + + ### `BytesKnownEncoding` {#BytesKnownEncoding} Known encoding to use on bytes diff --git a/packages/compiler/lib/std/decorators.tsp b/packages/compiler/lib/std/decorators.tsp index efd604dcd2..2875241d33 100644 --- a/packages/compiler/lib/std/decorators.tsp +++ b/packages/compiler/lib/std/decorators.tsp @@ -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 */ + 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). diff --git a/packages/http/README.md b/packages/http/README.md index 1d4e74dfb6..837b28a6ed 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -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` diff --git a/packages/http/generated-defs/TypeSpec.Http.ts b/packages/http/generated-defs/TypeSpec.Http.ts index c945e62b98..a14f1cec2a 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts @@ -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 = ( diff --git a/packages/http/lib/decorators.tsp b/packages/http/lib/decorators.tsp index 6afc9c7b8c..22267a2086 100644 --- a/packages/http/lib/decorators.tsp +++ b/packages/http/lib/decorators.tsp @@ -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( diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index 9315445ff2..e79fbe0011 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -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); }; diff --git a/packages/http/src/http-property.ts b/packages/http/src/http-property.ts index 0208161c3f..7d595769ba 100644 --- a/packages/http/src/http-property.ts +++ b/packages/http/src/http-property.ts @@ -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" }); diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 3a25fd0bfd..57629e986e 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -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, diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index 4dea2027ab..b4102a819e 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -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; diff --git a/packages/http/src/operations.ts b/packages/http/src/operations.ts index 6c9c584568..34cd279024 100644 --- a/packages/http/src/operations.ts +++ b/packages/http/src/operations.ts @@ -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, diff --git a/packages/http/src/parameters.ts b/packages/http/src/parameters.ts index 39c4c6c681..76b28f2d65 100644 --- a/packages/http/src/parameters.ts +++ b/packages/http/src/parameters.ts @@ -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", + }; + } + }, }) ); diff --git a/packages/http/src/route.ts b/packages/http/src/route.ts index e82da800a8..94fd0b01ce 100644 --- a/packages/http/src/route.ts +++ b/packages/http/src/route.ts @@ -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 { - 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 { 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 = { + 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, diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index 002df43e22..e7c6f12090 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -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> { 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 { 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[]; diff --git a/packages/http/src/uri-template.ts b/packages/http/src/uri-template.ts new file mode 100644 index 0000000000..677b3e1f9a --- /dev/null +++ b/packages/http/src/uri-template.ts @@ -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 }; +} diff --git a/packages/http/test/http-decorators.test.ts b/packages/http/test/http-decorators.test.ts index 3bd5329df0..9a8a07c54c 100644 --- a/packages/http/test/http-decorators.test.ts +++ b/packages/http/test/http-decorators.test.ts @@ -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(` + #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(` - 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: 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"); }); diff --git a/packages/http/test/routes.test.ts b/packages/http/test/routes.test.ts index d37306219a..c9a432bf6f 100644 --- a/packages/http/test/routes.test.ts +++ b/packages/http/test/routes.test.ts @@ -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.", + }); + }); + }); +}); diff --git a/packages/http/test/uri-template.test.ts b/packages/http/test/uri-template.test.ts new file mode 100644 index 0000000000..45cb7a3071 --- /dev/null +++ b/packages/http/test/uri-template.test.ts @@ -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 } }, + ]); +}); diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 64ef47e267..5a0995f68f 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -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: { diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index e0aed5d521..cfc8797138 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -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,36 +1402,81 @@ 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 mapHeaderParameterFormat( - parameter: HeaderFieldOptions & { - param: ModelProperty; + function getPathParameterAttributes(parameter: HttpOperationParameter & { type: "path" }) { + if (parameter.allowReserved) { + diagnostics.add( + createDiagnostic({ + code: "path-reserved-expansion", + target: parameter.param, + }) + ); } - ): { style?: string; explode?: boolean } | undefined { + + 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: - return {}; case "csv": case "simple": - return { style: "simple" }; + case "multi": + case "form": + return attributes; default: diagnostics.add( createDiagnostic({ code: "invalid-format", format: { - paramType: "header", + paramType: "query", value: parameter.format, }, target: parameter.param, @@ -1441,8 +1485,9 @@ function createOAPIEmitter( return undefined; } } - function mapQueryParameterFormat( - parameter: QueryParameterOptions & { + + function mapHeaderParameterFormat( + parameter: HeaderFieldOptions & { param: ModelProperty; } ): { style?: string; explode?: boolean } | undefined { @@ -1451,21 +1496,13 @@ function createOAPIEmitter( 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 }; - + return { style: "simple" }; default: diagnostics.add( createDiagnostic({ code: "invalid-format", format: { - paramType: "query", + paramType: "header", value: parameter.format, }, target: parameter.param, diff --git a/packages/openapi3/test/parameters.test.ts b/packages/openapi3/test/parameters.test.ts index ff4e2be2ee..964bfa4e8f 100644 --- a/packages/openapi3/test/parameters.test.ts +++ b/packages/openapi3/test/parameters.test.ts @@ -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 { + 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", () => { + async function getPathParam(code: string, name = "myParam"): Promise { + 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("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("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" }); }); }); }); diff --git a/packages/rest/src/rest.ts b/packages/rest/src/rest.ts index c5c3114c67..d4992ecf4f 100644 --- a/packages/rest/src/rest.ts +++ b/packages/rest/src/rest.ts @@ -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, diff --git a/packages/rest/tsconfig.json b/packages/rest/tsconfig.json index 284b90bcdc..6c3f24f797 100644 --- a/packages/rest/tsconfig.json +++ b/packages/rest/tsconfig.json @@ -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": ".", diff --git a/packages/samples/specs/visibility/visibility.tsp b/packages/samples/specs/visibility/visibility.tsp index 332f29d132..03f519c3b8 100644 --- a/packages/samples/specs/visibility/visibility.tsp +++ b/packages/samples/specs/visibility/visibility.tsp @@ -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; diff --git a/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml b/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml index 4da4186a02..85d990e5d3 100644 --- a/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml +++ b/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml @@ -86,7 +86,6 @@ paths: type: array items: type: string - style: form explode: true responses: '200':