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':