From 85f6c13e42f2d68a8dce28acb49a8a1132dfb975 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 19 Jul 2024 14:55:25 -0700 Subject: [PATCH 01/25] Initial --- packages/http/generated-defs/TypeSpec.Http.ts | 9 ++++++- packages/http/lib/decorators.tsp | 27 ++++++++++++++++++- packages/http/src/decorators.ts | 10 +++++-- packages/http/src/operations.ts | 1 + packages/http/src/route.ts | 1 + packages/http/src/types.ts | 13 +++++++-- 6 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/http/generated-defs/TypeSpec.Http.ts b/packages/http/generated-defs/TypeSpec.Http.ts index 2e90bed137..d37c0e243c 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts @@ -7,6 +7,13 @@ import type { Type, } from "@typespec/compiler"; +export interface PathOptions { + readonly name?: string; + readonly explode?: boolean; + readonly style?: "simple" | "label" | "matrix"; + 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. * @@ -89,7 +96,7 @@ export type QueryDecorator = ( export type PathDecorator = ( context: DecoratorContext, target: ModelProperty, - paramName?: string + paramNameOrOptions?: string | PathOptions ) => void; /** diff --git a/packages/http/lib/decorators.tsp b/packages/http/lib/decorators.tsp index 0ed9455701..1a244d16b8 100644 --- a/packages/http/lib/decorators.tsp +++ b/packages/http/lib/decorators.tsp @@ -68,6 +68,31 @@ model QueryOptions { */ extern dec query(target: ModelProperty, queryNameOrOptions?: 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. + */ + style?: "simple" | "label" | "matrix"; + + /** + * 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. * @@ -80,7 +105,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. diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index 9315445ff2..6870d5aa89 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -33,6 +33,7 @@ import { MultipartBodyDecorator, PatchDecorator, PathDecorator, + PathOptions, PostDecorator, PutDecorator, QueryDecorator, @@ -173,11 +174,16 @@ 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 options: PathParameterOptions = { type: "path", - name: paramName ?? entity.name, + ...(typeof paramNameOrOptions === "object" ? paramNameOrOptions : {}), + name: paramName, }; context.program.stateMap(HttpStateKeys.path).set(entity, options); }; diff --git a/packages/http/src/operations.ts b/packages/http/src/operations.ts index 6c9c584568..d10bef4450 100644 --- a/packages/http/src/operations.ts +++ b/packages/http/src/operations.ts @@ -221,6 +221,7 @@ function getHttpOperationInternal( const httpOperation: HttpOperation = { path: route.path, + uriTemplate: route.uriTemplate, pathSegments: route.pathSegments, verb: route.parameters.verb, container: operation.interface ?? operation.namespace ?? program.getGlobalNamespaceType(), diff --git a/packages/http/src/route.ts b/packages/http/src/route.ts index ef317240d2..edecf8d6f8 100644 --- a/packages/http/src/route.ts +++ b/packages/http/src/route.ts @@ -59,6 +59,7 @@ export function resolvePathAndParameters( overloadBase: HttpOperation | undefined, options: RouteResolutionOptions ): DiagnosticResult<{ + readonly uriTemplate: string; path: string; pathSegments: string[]; parameters: HttpOperationParameters; diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index 5732936799..87692a94c1 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 } from "../generated-defs/TypeSpec.Http.js"; import { HeaderProperty } from "./http-property.js"; /** @@ -309,7 +310,7 @@ export interface QueryParameterOptions { format?: "multi" | "csv" | "ssv" | "tsv" | "pipes" | "simple" | "form"; } -export interface PathParameterOptions { +export interface PathParameterOptions extends PathOptions { type: "path"; name: string; } @@ -359,7 +360,15 @@ 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; From 27b56f8f438c07e9e40cc58c6a3c9322cfc99614 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 19 Jul 2024 16:05:13 -0700 Subject: [PATCH 02/25] Basic uri template parsing --- packages/http/src/route.ts | 1 + packages/http/src/uri-template.ts | 49 +++++++++++++++++++++++++ packages/http/test/uri-template.test.ts | 33 +++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 packages/http/src/uri-template.ts create mode 100644 packages/http/test/uri-template.test.ts diff --git a/packages/http/src/route.ts b/packages/http/src/route.ts index edecf8d6f8..e97059d9e8 100644 --- a/packages/http/src/route.ts +++ b/packages/http/src/route.ts @@ -90,6 +90,7 @@ export function resolvePathAndParameters( } return diagnostics.wrap({ + uriTemplate: null as any, // TODO: do path: buildPath(segments), pathSegments: segments, parameters, diff --git a/packages/http/src/uri-template.ts b/packages/http/src/uri-template.ts new file mode 100644 index 0000000000..609994a951 --- /dev/null +++ b/packages/http/src/uri-template.ts @@ -0,0 +1,49 @@ +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 template: string; + 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 matches = template.matchAll(uriTemplateRegex); + for (let [_, expression] 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]; + parameters.push({ + name: name, + operator, + modifier: match[3] + ? { type: "explode" } + : match[2] + ? { type: "prefix", value: Number(match[2]) } + : undefined, + }); + } + } + } + return { template, parameters }; +} 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 } }, + ]); +}); From cc97ec93ec0e00ad30b0896a484d381da19752b0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 22 Jul 2024 10:52:37 -0700 Subject: [PATCH 03/25] Add uri template resolution and keep existing test working --- packages/http/src/index.ts | 1 - packages/http/src/lib.ts | 2 +- packages/http/src/operations.ts | 2 +- packages/http/src/parameters.ts | 15 +++--- packages/http/src/route.ts | 79 ++++++++++++++++++------------- packages/http/src/types.ts | 3 +- packages/http/src/uri-template.ts | 15 ++++-- packages/http/test/routes.test.ts | 2 +- 8 files changed, 69 insertions(+), 50 deletions(-) diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index c8daf7f58b..223fc75f3c 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -7,7 +7,6 @@ export * from "./content-types.js"; export * from "./decorators.js"; export * from "./metadata.js"; export * from "./operations.js"; -export * from "./parameters.js"; export { HttpPart, getHttpFileModel, diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index 4dea2027ab..1c2d6d0743 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -9,7 +9,7 @@ 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`, diff --git a/packages/http/src/operations.ts b/packages/http/src/operations.ts index d10bef4450..fa568cf45c 100644 --- a/packages/http/src/operations.ts +++ b/packages/http/src/operations.ts @@ -222,7 +222,7 @@ function getHttpOperationInternal( const httpOperation: HttpOperation = { path: route.path, uriTemplate: route.uriTemplate, - pathSegments: route.pathSegments, + pathSegments: [], // TODO: ? 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 9486c10193..f379ffc3b8 100644 --- a/packages/http/src/parameters.ts +++ b/packages/http/src/parameters.ts @@ -16,12 +16,13 @@ import { HttpVerb, OperationParameterOptions, } 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,30 +31,32 @@ 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); } function getOperationParametersForVerb( program: Program, operation: Operation, verb: HttpVerb, - knownPathParamNames: string[] + partialUriTemplate: string ): [HttpOperationParameters, readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); const visibility = resolveRequestVisibility(program, operation, verb); + const parsedUriTemplate = parseUriTemplate(partialUriTemplate); + function isImplicitPathParam(param: ModelProperty) { const isTopLevel = param.model === operation.parameters; - return isTopLevel && knownPathParamNames.includes(param.name); + return isTopLevel && parsedUriTemplate.parameters.some((x) => x.name === param.name); } const parameters: HttpOperationParameter[] = []; diff --git a/packages/http/src/route.ts b/packages/http/src/route.ts index e97059d9e8..8e65f0da7a 100644 --- a/packages/http/src/route.ts +++ b/packages/http/src/route.ts @@ -1,4 +1,5 @@ import { + createDiagnosticCollector, DecoratorContext, DiagnosticResult, Interface, @@ -6,10 +7,9 @@ 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, @@ -20,7 +20,7 @@ import { 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 = ["/", ":"]; @@ -61,14 +61,15 @@ export function resolvePathAndParameters( ): DiagnosticResult<{ readonly uriTemplate: string; path: string; - pathSegments: string[]; parameters: HttpOperationParameters; }> { const diagnostics = createDiagnosticCollector(); - const { segments, parameters } = diagnostics.pipe( + const { uriTemplate, parameters } = diagnostics.pipe( getRouteSegments(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) @@ -76,27 +77,40 @@ export function resolvePathAndParameters( // 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({ - uriTemplate: null as any, // TODO: do - 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 @@ -118,21 +132,16 @@ function getRouteSegments( 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, - }) - ); - - return diagnostics.wrap(result); + return routeProducer(program, operation, parentSegments, overloadBase, { + ...parentOptions, + ...options, + }); } /** @@ -164,33 +173,35 @@ 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 + : buildPath([...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").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); + for (const uriParam of parsedUriTemplate.parameters) { + unreferencedPathParamNames.delete(uriParam.name); } + const additionalSegments = []; // Add any remaining declared path params - for (const paramName of unreferencedPathParamNames) { - segments.push(`{${paramName}}`); + for (const [paramName] of unreferencedPathParamNames) { + additionalSegments.push(`{${paramName}}`); } return diagnostics.wrap({ - segments, + uriTemplate: buildPath([uriTemplate, ...additionalSegments]), parameters, }); } diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index 87692a94c1..bf86d07ef4 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -278,7 +278,7 @@ export interface RouteResolutionOptions extends RouteOptions { } export interface RouteProducerResult { - segments: string[]; + uriTemplate: string; parameters: HttpOperationParameters; } @@ -374,6 +374,7 @@ export interface HttpOperation { /** * 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 index 609994a951..677b3e1f9a 100644 --- a/packages/http/src/uri-template.ts +++ b/packages/http/src/uri-template.ts @@ -8,7 +8,7 @@ export interface UriTemplateParameter { } export interface UriTemplate { - readonly template: string; + readonly segments?: (string | UriTemplateParameter)[]; readonly parameters: UriTemplateParameter[]; } @@ -20,8 +20,9 @@ const expressionRegex = /([^:*]*)(?::(\d+)|(\*))?/; */ export function parseUriTemplate(template: string): UriTemplate { const parameters: UriTemplateParameter[] = []; + const segments: (string | UriTemplateParameter)[] = []; const matches = template.matchAll(uriTemplateRegex); - for (let [_, expression] of matches) { + for (let [_, expression, literal] of matches) { if (expression) { let operator: Operator | undefined; if (operators.includes(expression[0] as any)) { @@ -33,7 +34,7 @@ export function parseUriTemplate(template: string): UriTemplate { for (const item of items) { const match = item.match(expressionRegex)!; const name = match[1]; - parameters.push({ + const parameter: UriTemplateParameter = { name: name, operator, modifier: match[3] @@ -41,9 +42,13 @@ export function parseUriTemplate(template: string): UriTemplate { : match[2] ? { type: "prefix", value: Number(match[2]) } : undefined, - }); + }; + parameters.push(parameter); + segments.push(parameter); } + } else { + segments.push(literal); } } - return { template, parameters }; + return { segments, parameters }; } diff --git a/packages/http/test/routes.test.ts b/packages/http/test/routes.test.ts index d37306219a..0d78505e37 100644 --- a/packages/http/test/routes.test.ts +++ b/packages/http/test/routes.test.ts @@ -171,7 +171,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", }); }); From 0cb48dfb89b6a8a8301a7445c962e942546b2d92 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 22 Jul 2024 11:58:41 -0700 Subject: [PATCH 04/25] Implicit extract --- packages/http/generated-defs/TypeSpec.Http.ts | 2 +- packages/http/lib/decorators.tsp | 4 +- packages/http/src/decorators.ts | 6 +- packages/http/src/http-property.ts | 14 ++-- packages/http/src/parameters.ts | 49 ++++++++++++-- packages/http/src/route.ts | 4 +- packages/http/src/types.ts | 3 +- packages/http/test/routes.test.ts | 65 ++++++++++++++++++- 8 files changed, 125 insertions(+), 22 deletions(-) diff --git a/packages/http/generated-defs/TypeSpec.Http.ts b/packages/http/generated-defs/TypeSpec.Http.ts index d37c0e243c..63bd4ce305 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts @@ -10,7 +10,7 @@ import type { export interface PathOptions { readonly name?: string; readonly explode?: boolean; - readonly style?: "simple" | "label" | "matrix"; + readonly style?: "simple" | "label" | "matrix" | "fragment" | "path"; readonly allowReserved?: boolean; } diff --git a/packages/http/lib/decorators.tsp b/packages/http/lib/decorators.tsp index 1a244d16b8..aadb561469 100644 --- a/packages/http/lib/decorators.tsp +++ b/packages/http/lib/decorators.tsp @@ -83,8 +83,10 @@ model PathOptions { * - `simple`: No special encoding. * - `label`: Using `.` separator. * - `matrix`: `;` as separator. + * - `fragment`: `#` as separator. + * - `path`: `/` as separator. */ - style?: "simple" | "label" | "matrix"; + style?: "simple" | "label" | "matrix" | "fragment" | "path"; /** * When interpolating this parameter do not encode reserved characters. diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index 6870d5aa89..a7b0f294a3 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -180,9 +180,13 @@ export const $path: PathDecorator = ( typeof paramNameOrOptions === "string" ? paramNameOrOptions : paramNameOrOptions?.name ?? entity.name; + + const userOptions: PathOptions = typeof paramNameOrOptions === "object" ? paramNameOrOptions : {}; const options: PathParameterOptions = { type: "path", - ...(typeof paramNameOrOptions === "object" ? paramNameOrOptions : {}), + 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 fffa6b4047..bf2ea1fe5a 100644 --- a/packages/http/src/http-property.ts +++ b/packages/http/src/http-property.ts @@ -73,7 +73,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 @@ -99,13 +101,11 @@ export function getHttpProperty( }; const defined = Object.entries(annotations).filter((x) => !!x[1]); if (defined.length === 0) { - if (options.isImplicitPathParam && options.isImplicitPathParam(property)) { + const implicit = options.implicitParameter?.(property); + if (implicit) { return createResult({ - kind: "path", - options: { - name: property.name, - type: "path", - }, + kind: implicit.type, + options: implicit as any, property, }); } diff --git a/packages/http/src/parameters.ts b/packages/http/src/parameters.ts index f379ffc3b8..6a7ba28848 100644 --- a/packages/http/src/parameters.ts +++ b/packages/http/src/parameters.ts @@ -15,6 +15,8 @@ import { HttpOperationParameters, HttpVerb, OperationParameterOptions, + PathParameterOptions, + QueryParameterOptions, } from "./types.js"; import { parseUriTemplate } from "./uri-template.js"; @@ -44,6 +46,13 @@ export function getOperationParameters( : getOperationParametersForVerb(program, operation, "get", partialUriTemplate); } +const operatorToStyle = { + ";": "matrix", + "#": "fragment", + ".": "label", + "/": "path", +} as const; + function getOperationParametersForVerb( program: Program, operation: Operation, @@ -54,15 +63,43 @@ function getOperationParametersForVerb( const visibility = resolveRequestVisibility(program, operation, verb); const parsedUriTemplate = parseUriTemplate(partialUriTemplate); - function isImplicitPathParam(param: ModelProperty) { - const isTopLevel = param.model === operation.parameters; - return isTopLevel && parsedUriTemplate.parameters.some((x) => x.name === param.name); - } - 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; + } + + if (uriParam.operator === "?" || uriParam.operator === "&") { + return { + type: "query", + name: uriParam.name, + }; + } else if (uriParam.operator === "+") { + return { + type: "path", + name: uriParam.name, + explode: uriParam.modifier?.type === "explode", + allowReserved: true, + style: "simple", + }; + } else { + return { + type: "path", + name: uriParam.name, + explode: uriParam.modifier?.type === "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 8e65f0da7a..175a334e4c 100644 --- a/packages/http/src/route.ts +++ b/packages/http/src/route.ts @@ -72,7 +72,9 @@ export function resolvePathAndParameters( // 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 diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index bf86d07ef4..a460fbc0cb 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -310,9 +310,8 @@ export interface QueryParameterOptions { format?: "multi" | "csv" | "ssv" | "tsv" | "pipes" | "simple" | "form"; } -export interface PathParameterOptions extends PathOptions { +export interface PathParameterOptions extends Required { type: "path"; - name: string; } export type HttpOperationParameter = ( diff --git a/packages/http/test/routes.test.ts b/packages/http/test/routes.test.ts index 0d78505e37..7158546184 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, @@ -508,3 +509,61 @@ 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("foo"); + 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 }); + }); + + it("extract simple query parameter", async () => { + const param = await getParameter(`@route("/bar{?foo}") op foo(foo: string): void;`, "foo"); + strictEqual(param.type, "query"); + }); + + it("extract simple query continuation parameter", async () => { + const param = await getParameter( + `@route("/bar?fixed=yes{&foo}") op foo(foo: string): void;`, + "foo" + ); + strictEqual(param.type, "query"); + }); + }); +}); From 451705df7fc0f4f381bb02d7db1035b63111a747 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 22 Jul 2024 14:23:05 -0700 Subject: [PATCH 05/25] Route progress --- packages/compiler/src/core/library.ts | 6 +- packages/compiler/src/core/param-message.ts | 2 +- packages/http/src/http-property.ts | 35 ++++++++++- packages/http/src/lib.ts | 15 ++++- packages/http/src/payload.ts | 2 +- packages/http/src/route.ts | 38 +++++++++--- packages/http/test/routes.test.ts | 67 +++++++++++++++++++++ 7 files changed, 150 insertions(+), 15 deletions(-) diff --git a/packages/compiler/src/core/library.ts b/packages/compiler/src/core/library.ts index a8025ef17a..e9041ea85a 100644 --- a/packages/compiler/src/core/library.ts +++ b/packages/compiler/src/core/library.ts @@ -63,9 +63,9 @@ function createStateKeys( * const lib = createTypeSpecLibrary(libDef); */ export function createTypeSpecLibrary< - T extends { [code: string]: DiagnosticMessages }, - E extends Record, - State extends string = never, + const T extends { [code: string]: DiagnosticMessages }, + const E extends Record, + const State extends string = never, >(lib: Readonly>): TypeSpecLibrary { let emitterOptionValidator: JSONSchemaValidator; diff --git a/packages/compiler/src/core/param-message.ts b/packages/compiler/src/core/param-message.ts index df35803a69..8e8e5365ca 100644 --- a/packages/compiler/src/core/param-message.ts +++ b/packages/compiler/src/core/param-message.ts @@ -1,6 +1,6 @@ import type { CallableMessage } from "./types.js"; -export function paramMessage( +export function paramMessage( strings: readonly string[], ...keys: T ): CallableMessage { diff --git a/packages/http/src/http-property.ts b/packages/http/src/http-property.ts index bf2ea1fe5a..98e08b3dc2 100644 --- a/packages/http/src/http-property.ts +++ b/packages/http/src/http-property.ts @@ -100,8 +100,41 @@ export 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) { + } 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) { - const implicit = options.implicitParameter?.(property); if (implicit) { return createResult({ kind: implicit.type, diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index 1c2d6d0743..f4a291ba54 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -15,6 +15,19 @@ export const $lib = createTypeSpecLibrary({ 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: { @@ -187,6 +200,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/payload.ts b/packages/http/src/payload.ts index fda7a5536a..1beeeeef08 100644 --- a/packages/http/src/payload.ts +++ b/packages/http/src/payload.ts @@ -345,7 +345,7 @@ function resolveMultiPartBodyFromTuple( diagnostics.add( createDiagnostic({ code: "multipart-invalid-content-type", - format: { contentType, valid: multipartContentTypesValues.join(", ") }, + format: { contentType, supportedContentTypes: multipartContentTypesValues.join(", ") }, target: type, }) ); diff --git a/packages/http/src/route.ts b/packages/http/src/route.ts index 175a334e4c..42307431e2 100644 --- a/packages/http/src/route.ts +++ b/packages/http/src/route.ts @@ -13,7 +13,9 @@ import { createDiagnostic, HttpStateKeys, reportDiagnostic } from "./lib.js"; import { getOperationParameters } from "./parameters.js"; import { HttpOperation, + HttpOperationParameter, HttpOperationParameters, + PathParameterOptions, RouteOptions, RoutePath, RouteProducer, @@ -188,7 +190,9 @@ export function DefaultRouteProducer( // Pull out path parameters to verify what's in the path string const unreferencedPathParamNames = new Map( - parameters.parameters.filter(({ type }) => type === "path").map((x) => [x.name, x]) + 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 @@ -196,18 +200,36 @@ export function DefaultRouteProducer( unreferencedPathParamNames.delete(uriParam.name); } - const additionalSegments = []; - // Add any remaining declared path params - for (const [paramName] of unreferencedPathParamNames) { - additionalSegments.push(`{${paramName}}`); - } - + const resolvedUriTemplate = addOperationTemplateToUriTemplate(uriTemplate, parameters.parameters); return diagnostics.wrap({ - uriTemplate: buildPath([uriTemplate, ...additionalSegments]), + 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 = buildPath([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/test/routes.test.ts b/packages/http/test/routes.test.ts index 7158546184..5d76a2f727 100644 --- a/packages/http/test/routes.test.ts +++ b/packages/http/test/routes.test.ts @@ -566,4 +566,71 @@ describe("uri template", () => { strictEqual(param.type, "query"); }); }); + + 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.", + }); + }); + }); }); From 8cd53175dffc3bceb5747ec7c8f572b1ccc114eb Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 22 Jul 2024 14:38:14 -0700 Subject: [PATCH 06/25] Rest fix --- packages/http-server-javascript/src/common/reference.ts | 2 +- packages/http/src/index.ts | 1 + packages/http/src/route.ts | 8 +++++--- packages/http/test/http-decorators.test.ts | 3 +++ packages/http/test/multipart.test.ts | 2 +- packages/protobuf/src/transform/index.ts | 2 +- packages/rest/src/rest.ts | 5 +++-- 7 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/http-server-javascript/src/common/reference.ts b/packages/http-server-javascript/src/common/reference.ts index 7feeb3d69c..e6af69fa18 100644 --- a/packages/http-server-javascript/src/common/reference.ts +++ b/packages/http-server-javascript/src/common/reference.ts @@ -253,7 +253,7 @@ export function emitTypeReference( default: reportDiagnostic(ctx.program, { code: "unrecognized-intrinsic", - format: { name: (type satisfies never as IntrinsicType).name }, + format: { intrinsic: (type satisfies never as IntrinsicType).name }, target: position, }); return "unknown"; diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 223fc75f3c..34146007a0 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -7,6 +7,7 @@ export * from "./content-types.js"; export * from "./decorators.js"; export * from "./metadata.js"; export * from "./operations.js"; +export { getOperationParameters } from "./parameters.js"; export { HttpPart, getHttpFileModel, diff --git a/packages/http/src/route.ts b/packages/http/src/route.ts index 42307431e2..d749b95185 100644 --- a/packages/http/src/route.ts +++ b/packages/http/src/route.ts @@ -39,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); @@ -180,7 +180,7 @@ export function DefaultRouteProducer( const uriTemplate = !routePath && overloadBase ? overloadBase.uriTemplate - : buildPath([...parentSegments, ...(routePath ? [routePath] : [])]); + : joinPathSegments([...parentSegments, ...(routePath ? [routePath] : [])]); const parsedUriTemplate = parseUriTemplate(uriTemplate); @@ -200,7 +200,9 @@ export function DefaultRouteProducer( unreferencedPathParamNames.delete(uriParam.name); } - const resolvedUriTemplate = addOperationTemplateToUriTemplate(uriTemplate, parameters.parameters); + const resolvedUriTemplate = addOperationTemplateToUriTemplate(uriTemplate, [ + ...unreferencedPathParamNames.values(), + ]); return diagnostics.wrap({ uriTemplate: resolvedUriTemplate, parameters, diff --git a/packages/http/test/http-decorators.test.ts b/packages/http/test/http-decorators.test.ts index 3bd5329df0..9cc4303a2c 100644 --- a/packages/http/test/http-decorators.test.ts +++ b/packages/http/test/http-decorators.test.ts @@ -372,6 +372,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/multipart.test.ts b/packages/http/test/multipart.test.ts index 19989fd379..735a2e8e27 100644 --- a/packages/http/test/multipart.test.ts +++ b/packages/http/test/multipart.test.ts @@ -13,7 +13,7 @@ it("emit diagnostic when using invalid content type for multipart ", async () => expectDiagnostics(diagnostics, { code: "@typespec/http/multipart-invalid-content-type", message: - "Content type 'application/json' is not a multipart content type. Supported content types are: .", + "Content type 'application/json' is not a multipart content type. Supported content types are: multipart/form-data, multipart/mixed.", }); }); diff --git a/packages/protobuf/src/transform/index.ts b/packages/protobuf/src/transform/index.ts index 8a17febf32..1d506a9ebf 100644 --- a/packages/protobuf/src/transform/index.ts +++ b/packages/protobuf/src/transform/index.ts @@ -442,7 +442,7 @@ function tspToProto(program: Program, emitterOptions: ProtobufEmitterOptions): P reportDiagnostic(program, { code: "unsupported-intrinsic", - format: { type: t.name }, + format: { name: t.name }, target: t, }); 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, From 905f24811b96bd99224a3caa5538ce5726cad84b Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 22 Jul 2024 14:46:03 -0700 Subject: [PATCH 07/25] Fix rest --- packages/http/src/route.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/http/src/route.ts b/packages/http/src/route.ts index d749b95185..48706195a4 100644 --- a/packages/http/src/route.ts +++ b/packages/http/src/route.ts @@ -67,7 +67,7 @@ export function resolvePathAndParameters( }> { const diagnostics = createDiagnosticCollector(); const { uriTemplate, parameters } = diagnostics.pipe( - getRouteSegments(program, operation, overloadBase, options) + getUriTemplateAndParameters(program, operation, overloadBase, options) ); const parsedUriTemplate = parseUriTemplate(uriTemplate); @@ -130,7 +130,7 @@ function collectSegmentsAndOptions( return [[...parentSegments, ...(route ? [route] : [])], { ...parentOptions, ...options }]; } -function getRouteSegments( +function getUriTemplateAndParameters( program: Program, operation: Operation, overloadBase: HttpOperation | undefined, @@ -142,10 +142,15 @@ function getRouteSegments( ); const routeProducer = getRouteProducer(program, operation) ?? DefaultRouteProducer; - return routeProducer(program, operation, parentSegments, overloadBase, { + const [result, diagnostics] = routeProducer(program, operation, parentSegments, overloadBase, { ...parentOptions, ...options, }); + + return [ + { uriTemplate: buildPath([result.uriTemplate]), parameters: result.parameters }, + diagnostics, + ]; } /** @@ -226,7 +231,7 @@ function addOperationTemplateToUriTemplate(uriTemplate: string, params: HttpOper }); const queryParams = params.filter((x) => x.type === "query"); - const pathPart = buildPath([uriTemplate, ...pathParams]); + const pathPart = joinPathSegments([uriTemplate, ...pathParams]); return ( pathPart + (queryParams.length > 0 ? `{?${queryParams.map((x) => x.name).join(",")}}` : "") ); From 36084eca63cbf8f0e04f2ced34fcbdb5671a981f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 24 Jul 2024 13:04:57 -0700 Subject: [PATCH 08/25] Some doc fixes --- docs/libraries/http/reference/data-types.md | 15 ++++++++ docs/libraries/http/reference/decorators.md | 40 +++++++++++++-------- docs/libraries/http/reference/index.mdx | 1 + packages/http/README.md | 40 +++++++++++++-------- packages/http/lib/decorators.tsp | 26 ++++++++------ packages/rest/tsconfig.json | 2 +- 6 files changed, 83 insertions(+), 41 deletions(-) diff --git a/docs/libraries/http/reference/data-types.md b/docs/libraries/http/reference/data-types.md index 6456044669..eb58621f1b 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`, diff --git a/docs/libraries/http/reference/decorators.md b/docs/libraries/http/reference/decorators.md index 26c688c55b..df99c4324e 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 @@ -375,11 +375,7 @@ op list( ### `@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 +389,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` | Relative route path. Cannot include query parameters. | +| 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): Widget[]; +@route("/files{?select,filter}") op listFullUriTemplateselect?: string, filter?: string): Widget[]; ``` ### `@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/packages/http/README.md b/packages/http/README.md index 488f8d7855..7cec458155 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 @@ -423,11 +423,7 @@ op list( #### `@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 +437,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` | Relative route path. Cannot include query parameters. | +| 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): Widget[]; +@route("/files{?select,filter}") op listFullUriTemplateselect?: string, filter?: string): Widget[]; ``` #### `@server` diff --git a/packages/http/lib/decorators.tsp b/packages/http/lib/decorators.tsp index aadb561469..36cf9c8952 100644 --- a/packages/http/lib/decorators.tsp +++ b/packages/http/lib/decorators.tsp @@ -98,7 +98,7 @@ model PathOptions { /** * 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 * @@ -297,22 +297,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 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): Widget[]; + * @route("/files{?select,filter}") op listFullUriTemplateselect?: string, filter?: string): Widget[]; * ``` */ extern dec route( 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": ".", From 2565b9f4a39e14cf167d6c3ad7a0aac013421333 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 24 Jul 2024 14:29:07 -0700 Subject: [PATCH 09/25] Add path parameter tests --- packages/openapi3/src/lib.ts | 12 +++ packages/openapi3/src/openapi.ts | 49 ++++++++- packages/openapi3/test/parameters.test.ts | 121 ++++++++++++++++++++++ 3 files changed, 177 insertions(+), 5 deletions(-) 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 42ff61b8be..ee82dc5079 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1576,17 +1576,17 @@ function createOAPIEmitter( ph = mergeOpenApiParameters(ph, paramBase); } - const format = mapParameterFormat(parameter); - if (format === undefined) { + const attributes = getParameterAttributes(parameter); + if (attributes === undefined) { ph.schema = { type: "string", }; } else { - Object.assign(ph, format); + Object.assign(ph, attributes); } } - function mapParameterFormat( + function getParameterAttributes( parameter: HttpOperationParameter ): { style?: string; explode?: boolean } | undefined { switch (parameter.type) { @@ -1594,11 +1594,50 @@ function createOAPIEmitter( return mapHeaderParameterFormat(parameter); case "query": return mapQueryParameterFormat(parameter); + case "path": - return {}; + return getPathParameterAttributes(parameter); } } + function getPathParameterAttributes(parameter: HttpOperationParameter & { type: "path" }) { + if (parameter.allowReserved) { + diagnostics.add( + createDiagnostic({ + code: "path-reserved-expansion", + target: parameter.param, + }) + ); + } + + const attributes: { style?: string; explode?: boolean } = {}; + + if (parameter.explode) { + attributes.explode = true; + } + + switch (parameter.style) { + case "label": + attributes.style = "label"; + break; + case "matrix": + attributes.style = "matrix"; + break; + case "simple": + break; + default: + diagnostics.add( + createDiagnostic({ + code: "invalid-style", + format: { style: parameter.style, paramType: "path" }, + target: parameter.param, + }) + ); + } + + return attributes; + } + function mapHeaderParameterFormat( parameter: HeaderFieldOptions & { param: ModelProperty; diff --git a/packages/openapi3/test/parameters.test.ts b/packages/openapi3/test/parameters.test.ts index ff4e2be2ee..139e383e18 100644 --- a/packages/openapi3/test/parameters.test.ts +++ b/packages/openapi3/test/parameters.test.ts @@ -1,6 +1,7 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; +import { OpenAPI3PathParameter } from "../src/types.js"; import { diagnoseOpenApiFor, openApiFor } from "./test-host.js"; describe("openapi3: parameters", () => { @@ -428,5 +429,125 @@ describe("openapi3: parameters", () => { const res = await openApiFor(`op test(@path("my-custom-path") myParam: string): void;`); expect(res.paths).toHaveProperty("/{my-custom-path}"); }); + + async function getParam(code: string): Promise { + const res = await openApiFor(code); + return res.paths["/{myParam}"].get.parameters[0]; + } + + describe("mark parameter with explode: true", () => { + it("with option", async () => { + const param = await getParam(`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 getParam(`@route("{myParam*}") op test(myParam: string[]): void;`); + expect(param).toMatchObject({ + explode: true, + schema: { + type: "array", + items: { type: "string" }, + }, + }); + }); + }); + + describe("mark parameter with style: simple", () => { + it("with option", async () => { + const param = await getParam(`op test(@path(#{style: "simple"}) myParam: string): void;`); + expect(param).not.toHaveProperty("style"); + }); + + it("with uri template", async () => { + const param = await getParam(`@route("{myParam}") op test(myParam: string): void;`); + expect(param).not.toHaveProperty("style"); + }); + }); + + describe("mark parameter with style: label", () => { + it("with option", async () => { + const param = await getParam(`op test(@path(#{style: "label"}) myParam: string): void;`); + expect(param).toMatchObject({ + style: "label", + }); + }); + + it("with uri template", async () => { + const param = await getParam(`@route("{.myParam}") op test(myParam: string): void;`); + expect(param).toMatchObject({ + style: "label", + }); + }); + }); + + describe("mark parameter with style: matrix", () => { + it("with option", async () => { + const param = await getParam(`op test(@path(#{style: "matrix"}) myParam: string): void;`); + expect(param).toMatchObject({ + style: "matrix", + }); + }); + + it("with uri template", async () => { + const param = await getParam(`@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" }); + }); + }); }); }); From a392551d7702f0fc198fb65da50ab23d922974af Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 24 Jul 2024 14:36:00 -0700 Subject: [PATCH 10/25] Create uri-templates-2024-6-24-20-7-39.md --- .chronus/changes/uri-templates-2024-6-24-20-7-39.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .chronus/changes/uri-templates-2024-6-24-20-7-39.md 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; + ``` From d8d960bef7c7f88edc554c63e6624e505090f40d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 25 Jul 2024 08:42:36 -0700 Subject: [PATCH 11/25] Create uri-templates-2024-6-24-21-37-52.md --- .chronus/changes/uri-templates-2024-6-24-21-37-52.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .chronus/changes/uri-templates-2024-6-24-21-37-52.md 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 From 3653a3591f7401b0a2f21578061bdd01d164c43f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 25 Jul 2024 09:27:33 -0700 Subject: [PATCH 12/25] ADd explode support to query --- .../changes/uri-templates-2024-6-25-9-3-39.md | 13 + packages/http/generated-defs/TypeSpec.Http.ts | 33 ++- packages/http/lib/decorators.tsp | 15 +- packages/http/src/decorators.ts | 49 ++-- packages/http/src/lib.ts | 6 - packages/http/src/parameters.ts | 6 +- packages/http/src/types.ts | 10 +- packages/http/test/http-decorators.test.ts | 48 ++-- packages/openapi3/src/openapi.ts | 2 +- packages/openapi3/test/parameters.test.ts | 250 ++++++++++-------- 10 files changed, 246 insertions(+), 186 deletions(-) create mode 100644 .chronus/changes/uri-templates-2024-6-25-9-3-39.md 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/packages/http/generated-defs/TypeSpec.Http.ts b/packages/http/generated-defs/TypeSpec.Http.ts index 63bd4ce305..ea0063f415 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts @@ -7,6 +7,12 @@ 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; @@ -80,13 +86,13 @@ export type HeaderDecorator = ( 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}") @@ -258,20 +264,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 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): Widget[]; + * @route("/files{?select,filter}") op listFullUriTemplateselect?: string, filter?: string): Widget[]; * ``` */ export type RouteDecorator = ( diff --git a/packages/http/lib/decorators.tsp b/packages/http/lib/decorators.tsp index 36cf9c8952..7afb7f11e4 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"; } @@ -66,7 +79,7 @@ model QueryOptions { * op list(@query({name: "id", format: "multi"}) 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. */ diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index a7b0f294a3..bb905e83e3 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -37,6 +37,7 @@ import { PostDecorator, PutDecorator, QueryDecorator, + QueryOptions, RouteDecorator, ServerDecorator, SharedRouteDecorator, @@ -124,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, + 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); }; @@ -179,7 +170,7 @@ export const $path: PathDecorator = ( const paramName = typeof paramNameOrOptions === "string" ? paramNameOrOptions - : paramNameOrOptions?.name ?? entity.name; + : (paramNameOrOptions?.name ?? entity.name); const userOptions: PathOptions = typeof paramNameOrOptions === "object" ? paramNameOrOptions : {}; const options: PathParameterOptions = { diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index f4a291ba54..b4102a819e 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -166,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" }, diff --git a/packages/http/src/parameters.ts b/packages/http/src/parameters.ts index 6a7ba28848..38787ecfd1 100644 --- a/packages/http/src/parameters.ts +++ b/packages/http/src/parameters.ts @@ -77,16 +77,18 @@ function getOperationParametersForVerb( 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: uriParam.modifier?.type === "explode", + explode, allowReserved: true, style: "simple", }; @@ -94,7 +96,7 @@ function getOperationParametersForVerb( return { type: "path", name: uriParam.name, - explode: uriParam.modifier?.type === "explode", + explode, allowReserved: false, style: (uriParam.operator && operatorToStyle[uriParam.operator]) ?? "simple", }; diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index a460fbc0cb..48f489620a 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -10,7 +10,7 @@ import { Tuple, Type, } from "@typespec/compiler"; -import { PathOptions } from "../generated-defs/TypeSpec.Http.js"; +import { PathOptions, QueryOptions } from "../generated-defs/TypeSpec.Http.js"; import { HeaderProperty } from "./http-property.js"; /** @@ -300,14 +300,12 @@ 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 extends Required { diff --git a/packages/http/test/http-decorators.test.ts b/packages/http/test/http-decorators.test.ts index 9cc4303a2c..1fae11bdf5 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,42 @@ 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", + 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, }); }); diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index ee82dc5079..12b2308c5f 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1670,7 +1670,7 @@ function createOAPIEmitter( ): { style?: string; explode?: boolean } | undefined { switch (parameter.format) { case undefined: - return {}; + return { explode: parameter.explode === true ? true : undefined }; case "csv": case "simple": return { style: "form", explode: false }; diff --git a/packages/openapi3/test/parameters.test.ts b/packages/openapi3/test/parameters.test.ts index 139e383e18..a269c78d79 100644 --- a/packages/openapi3/test/parameters.test.ts +++ b/packages/openapi3/test/parameters.test.ts @@ -1,34 +1,56 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; -import { OpenAPI3PathParameter } from "../src/types.js"; +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"); + }); + + 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("create a query param of array type", async () => { + 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[], @@ -418,136 +440,136 @@ describe("openapi3: parameters", () => { ok(res.paths["/"].post.requestBody.content["application/json"]); }); }); +}); - describe("path parameters", () => { - it("figure out the route parameter from the name of the param", async () => { - const res = await openApiFor(`op test(@path myParam: string): void;`); - expect(res.paths).toHaveProperty("/{myParam}"); - }); +describe("path parameters", () => { + async function getPathParam(code: string, name = "myParam"): Promise { + const res = await openApiFor(code); + return res.paths[`/{${name}}`].get.parameters[0]; + } - 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("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}"); + }); - async function getParam(code: string): Promise { - const res = await openApiFor(code); - return res.paths["/{myParam}"].get.parameters[0]; - } + 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("mark parameter with explode: true", () => { - it("with option", async () => { - const param = await getParam(`op test(@path(#{explode: true}) myParam: string[]): void;`); - expect(param).toMatchObject({ - explode: true, - schema: { - type: "array", - items: { type: "string" }, - }, - }); + 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 getParam(`@route("{myParam*}") op test(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("mark parameter with style: simple", () => { - it("with option", async () => { - const param = await getParam(`op test(@path(#{style: "simple"}) myParam: string): void;`); - expect(param).not.toHaveProperty("style"); - }); + 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("with uri template", async () => { - const param = await getParam(`@route("{myParam}") op test(myParam: string): void;`); - expect(param).not.toHaveProperty("style"); - }); + it("with uri template", async () => { + const param = await getPathParam(`@route("{myParam}") op test(myParam: string): void;`); + expect(param).not.toHaveProperty("style"); }); + }); - describe("mark parameter with style: label", () => { - it("with option", async () => { - const param = await getParam(`op test(@path(#{style: "label"}) myParam: string): void;`); - expect(param).toMatchObject({ - style: "label", - }); + 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 getParam(`@route("{.myParam}") op test(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("mark parameter with style: matrix", () => { - it("with option", async () => { - const param = await getParam(`op test(@path(#{style: "matrix"}) myParam: string): void;`); - expect(param).toMatchObject({ - style: "matrix", - }); + 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 getParam(`@route("{;myParam}") op test(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" }); - }); + 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" }); - }); + 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" }); - }); + 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" }); - }); + 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" }); - }); + 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" }); - }); + it("with uri template", async () => { + const diagnostics = await diagnoseOpenApiFor( + `@route("{+myParam}") op test(myParam: string): void;` + ); + expectDiagnostics(diagnostics, { code: "@typespec/openapi3/path-reserved-expansion" }); }); }); }); From e990691ea7b2dedc50baa93d835f7d74e1ae2e9e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 25 Jul 2024 09:56:53 -0700 Subject: [PATCH 13/25] regen samples --- packages/samples/specs/visibility/visibility.tsp | 4 +--- .../test/output/visibility/@typespec/openapi3/openapi.yaml | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) 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': From b250d05da9268b989e200d548ad3497cf32cb205 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 25 Jul 2024 15:33:15 -0700 Subject: [PATCH 14/25] Update packages/http/lib/decorators.tsp Co-authored-by: Christopher Radek <14189820+chrisradek@users.noreply.github.com> --- packages/http/lib/decorators.tsp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/http/lib/decorators.tsp b/packages/http/lib/decorators.tsp index f8c8cb9792..13b7866856 100644 --- a/packages/http/lib/decorators.tsp +++ b/packages/http/lib/decorators.tsp @@ -342,8 +342,8 @@ extern dec includeInapplicableMetadataInPayload(target: unknown, value: valueof * * @example Query parameter * ```typespec - * @route("/files") op list(select?: string, filter?: string): Widget[]; - * @route("/files{?select,filter}") op listFullUriTemplateselect?: string, filter?: string): Widget[]; + * @route("/files") op list(select?: string, filter?: string): Files[]; + * @route("/files{?select,filter}") op listFullUriTemplate(select?: string, filter?: string): Files[]; * ``` */ extern dec route( From 2b1d1dc7bb8e4e53eda184f22f7f7e4fde545dae Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 26 Jul 2024 14:00:50 -0700 Subject: [PATCH 15/25] regen --- packages/http/generated-defs/TypeSpec.Http.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/http/generated-defs/TypeSpec.Http.ts b/packages/http/generated-defs/TypeSpec.Http.ts index 69bd7dd23d..58658996b5 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts @@ -290,8 +290,8 @@ export type IncludeInapplicableMetadataInPayloadDecorator = ( * ``` * @example Query parameter * ```typespec - * @route("/files") op list(select?: string, filter?: string): Widget[]; - * @route("/files{?select,filter}") op listFullUriTemplateselect?: string, filter?: string): Widget[]; + * @route("/files") op list(select?: string, filter?: string): Files[]; + * @route("/files{?select,filter}") op listFullUriTemplate(select?: string, filter?: string): Files[]; * ``` */ export type RouteDecorator = ( From 661a1bb5dae435013257d8646c102b4934aab238 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 26 Jul 2024 14:16:04 -0700 Subject: [PATCH 16/25] regen docs --- docs/libraries/http/reference/data-types.md | 9 +++++---- docs/libraries/http/reference/decorators.md | 12 ++++++------ packages/http/README.md | 12 ++++++------ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/docs/libraries/http/reference/data-types.md b/docs/libraries/http/reference/data-types.md index eb58621f1b..549e8fc88d 100644 --- a/docs/libraries/http/reference/data-types.md +++ b/docs/libraries/http/reference/data-types.md @@ -510,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 c1bb0dc9a7..fd13001cec 100644 --- a/docs/libraries/http/reference/decorators.md +++ b/docs/libraries/http/reference/decorators.md @@ -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,9 +356,9 @@ 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 @@ -411,8 +411,8 @@ Defines the relative route URI template for the target operation as defined by [ ##### Query parameter ```typespec -@route("/files") op list(select?: string, filter?: string): Widget[]; -@route("/files{?select,filter}") op listFullUriTemplateselect?: string, filter?: string): Widget[]; +@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/packages/http/README.md b/packages/http/README.md index f05f725eea..6d11675b0c 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -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,9 +404,9 @@ 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 @@ -459,8 +459,8 @@ Defines the relative route URI template for the target operation as defined by [ ###### Query parameter ```typespec -@route("/files") op list(select?: string, filter?: string): Widget[]; -@route("/files{?select,filter}") op listFullUriTemplateselect?: string, filter?: string): Widget[]; +@route("/files") op list(select?: string, filter?: string): Files[]; +@route("/files{?select,filter}") op listFullUriTemplate(select?: string, filter?: string): Files[]; ``` #### `@server` From a9813689d058c168b2aa61edca7fe3cb654e208c Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 31 Jul 2024 13:14:43 -0700 Subject: [PATCH 17/25] Fix CR comments --- .chronus/changes/uri-templates-2024-6-25-9-3-39-2.md | 7 +++++++ packages/http/lib/decorators.tsp | 4 ++-- packages/http/src/operations.ts | 2 +- packages/http/test/routes.test.ts | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 .chronus/changes/uri-templates-2024-6-25-9-3-39-2.md 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/packages/http/lib/decorators.tsp b/packages/http/lib/decorators.tsp index f8c8cb9792..8fb9eb01f1 100644 --- a/packages/http/lib/decorators.tsp +++ b/packages/http/lib/decorators.tsp @@ -76,7 +76,7 @@ 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?: valueof string | QueryOptions); @@ -326,7 +326,7 @@ extern dec includeInapplicableMetadataInPayload(target: unknown, value: valueof * * `@route` can only be applied to operations, namespaces, and interfaces. * - * @param path Relative route path. Cannot include query parameters. + * @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 diff --git a/packages/http/src/operations.ts b/packages/http/src/operations.ts index fa568cf45c..34cd279024 100644 --- a/packages/http/src/operations.ts +++ b/packages/http/src/operations.ts @@ -222,7 +222,7 @@ function getHttpOperationInternal( const httpOperation: HttpOperation = { path: route.path, uriTemplate: route.uriTemplate, - pathSegments: [], // TODO: ? + pathSegments: [], verb: route.parameters.verb, container: operation.interface ?? operation.namespace ?? program.getGlobalNamespaceType(), parameters: route.parameters, diff --git a/packages/http/test/routes.test.ts b/packages/http/test/routes.test.ts index 5d76a2f727..cd118f01d4 100644 --- a/packages/http/test/routes.test.ts +++ b/packages/http/test/routes.test.ts @@ -520,7 +520,7 @@ describe("uri template", () => { const op = await getOp(code); const param = op.parameters.parameters.find((x) => x.name === name); ok(param); - expect(param.name).toEqual("foo"); + expect(param.name).toEqual(name); return param; } From 9d20aa736a26624ce5b9c1c6687b2b59b66af6f5 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 31 Jul 2024 13:55:42 -0700 Subject: [PATCH 18/25] regen --- docs/libraries/http/reference/decorators.md | 10 ++-------- packages/http/README.md | 10 ++-------- packages/http/generated-defs/TypeSpec.Http.ts | 4 ++-- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/docs/libraries/http/reference/decorators.md b/docs/libraries/http/reference/decorators.md index fd13001cec..a55f429071 100644 --- a/docs/libraries/http/reference/decorators.md +++ b/docs/libraries/http/reference/decorators.md @@ -364,13 +364,7 @@ Specify this property is to be sent as a query parameter. ```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} @@ -391,7 +385,7 @@ Defines the relative route URI template for the target operation as defined by [ | Name | Type | Description | | ------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| path | `valueof string` | Relative route path. Cannot include query parameters. | +| 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 diff --git a/packages/http/README.md b/packages/http/README.md index 6d11675b0c..837b28a6ed 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -412,13 +412,7 @@ Specify this property is to be sent as a query parameter. ```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` @@ -439,7 +433,7 @@ Defines the relative route URI template for the target operation as defined by [ | Name | Type | Description | | ------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| path | `valueof string` | Relative route path. Cannot include query parameters. | +| 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 diff --git a/packages/http/generated-defs/TypeSpec.Http.ts b/packages/http/generated-defs/TypeSpec.Http.ts index 58658996b5..a14f1cec2a 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts @@ -80,7 +80,7 @@ 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 = ( @@ -277,7 +277,7 @@ export type IncludeInapplicableMetadataInPayloadDecorator = ( * * `@route` can only be applied to operations, namespaces, and interfaces. * - * @param path Relative route path. Cannot include query parameters. + * @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 * From 38b842209f0cb58a669fe30695a30e4f527100d7 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 2 Aug 2024 12:23:47 -0700 Subject: [PATCH 19/25] keep legacy format --- packages/http/src/decorators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index bb905e83e3..e79fbe0011 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -144,7 +144,7 @@ export const $query: QueryDecorator = ( type: "query", explode: userOptions.explode ?? (userOptions.format === "multi" || userOptions.format === "form"), - format: userOptions.format, + format: userOptions.format ?? (userOptions.explode ? "multi" : "csv"), name: paramName, }; context.program.stateMap(HttpStateKeys.query).set(entity, options); From 6eba177c4cf17eadce4d61d9892e0787900265cf Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 5 Aug 2024 09:04:46 -0700 Subject: [PATCH 20/25] Fix --- packages/openapi3/src/openapi.ts | 44 +++++++++++------------ packages/openapi3/test/parameters.test.ts | 3 -- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 631497965a..2b37d94ac1 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -75,7 +75,6 @@ import { isOrExtendsHttpFile, isOverloadSameEndpoint, MetadataInfo, - QueryParameterOptions, reportIfNoRoutes, resolveAuthentication, resolveRequestVisibility, @@ -1521,8 +1520,7 @@ function createOAPIEmitter( case "header": return mapHeaderParameterFormat(parameter); case "query": - return mapQueryParameterFormat(parameter); - + return getQueryParameterAttributes(parameter); case "path": return getPathParameterAttributes(parameter); } @@ -1566,23 +1564,30 @@ function createOAPIEmitter( return attributes; } - function mapHeaderParameterFormat( - parameter: HeaderFieldOptions & { - param: ModelProperty; + function getQueryParameterAttributes(parameter: HttpOperationParameter & { type: "query" }) { + const attributes: { style?: string; explode?: boolean } = {}; + + if (parameter.explode) { + attributes.explode = true; } - ): { style?: string; explode?: boolean } | undefined { + 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, @@ -1591,31 +1596,24 @@ function createOAPIEmitter( return undefined; } } - function mapQueryParameterFormat( - parameter: QueryParameterOptions & { + + function mapHeaderParameterFormat( + parameter: HeaderFieldOptions & { param: ModelProperty; } ): { style?: string; explode?: boolean } | undefined { switch (parameter.format) { case undefined: - return { explode: parameter.explode === true ? true : undefined }; + return {}; case "csv": case "simple": - return { style: "form", explode: false }; - case "multi": - case "form": - return { style: "form", explode: true }; - case "ssv": - return { style: "spaceDelimited", explode: false }; - case "pipes": - return { style: "pipeDelimited", explode: false }; - + 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 a269c78d79..964bfa4e8f 100644 --- a/packages/openapi3/test/parameters.test.ts +++ b/packages/openapi3/test/parameters.test.ts @@ -65,7 +65,6 @@ describe("query parameters", () => { deepStrictEqual(params[0], { in: "query", name: "$multi", - style: "form", required: true, explode: true, schema: { @@ -78,8 +77,6 @@ describe("query parameters", () => { deepStrictEqual(params[1], { in: "query", name: "$csv", - style: "form", - explode: false, schema: { type: "array", items: { From 6d922f70955768d35d31a86de6e54ec77f6f24ba Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 5 Aug 2024 13:22:56 -0700 Subject: [PATCH 21/25] Fix --- packages/http/test/http-decorators.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/http/test/http-decorators.test.ts b/packages/http/test/http-decorators.test.ts index 1fae11bdf5..9a8a07c54c 100644 --- a/packages/http/test/http-decorators.test.ts +++ b/packages/http/test/http-decorators.test.ts @@ -198,6 +198,7 @@ describe("http: decorators", () => { expect(getQueryParamOptions(runner.program, selects)).toEqual({ type: "query", name: "selects", + format: "multi", explode: true, }); }); From 6d950fbc336b53363f1013d859adab0b6362f45b Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 5 Aug 2024 18:55:32 -0700 Subject: [PATCH 22/25] add more tests --- packages/http/test/routes.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/http/test/routes.test.ts b/packages/http/test/routes.test.ts index cd118f01d4..8827d43f52 100644 --- a/packages/http/test/routes.test.ts +++ b/packages/http/test/routes.test.ts @@ -553,9 +553,20 @@ describe("uri template", () => { 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"); - strictEqual(param.type, "query"); + 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 () => { @@ -563,7 +574,7 @@ describe("uri template", () => { `@route("/bar?fixed=yes{&foo}") op foo(foo: string): void;`, "foo" ); - strictEqual(param.type, "query"); + expectQueryParameter(param, { explode: false }); }); }); From 08fc78126b7cc7278550ec5b977d145d6c11e36f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 6 Aug 2024 09:37:13 -0700 Subject: [PATCH 23/25] Fixes --- packages/compiler/lib/std/decorators.tsp | 11 +++++++++++ packages/http/src/http-property.ts | 11 +++++++++++ packages/http/src/types.ts | 17 ++++++++++++----- packages/http/test/routes.test.ts | 13 +++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) 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/src/http-property.ts b/packages/http/src/http-property.ts index 41950d980a..7d595769ba 100644 --- a/packages/http/src/http-property.ts +++ b/packages/http/src/http-property.ts @@ -124,6 +124,17 @@ function getHttpProperty( ); } } 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({ diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index 41379802b2..e7c6f12090 100644 --- a/packages/http/src/types.ts +++ b/packages/http/src/types.ts @@ -312,11 +312,18 @@ export interface PathParameterOptions extends Required { type: "path"; } -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; }; diff --git a/packages/http/test/routes.test.ts b/packages/http/test/routes.test.ts index 8827d43f52..c9a432bf6f 100644 --- a/packages/http/test/routes.test.ts +++ b/packages/http/test/routes.test.ts @@ -644,4 +644,17 @@ describe("uri template", () => { }); }); }); + + 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.", + }); + }); + }); }); From c9bd8ba00ea06301632f5d8a5ddd3262361c22d1 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 6 Aug 2024 10:34:45 -0700 Subject: [PATCH 24/25] Create uri-templates-2024-7-6-16-39-59.md --- .chronus/changes/uri-templates-2024-7-6-16-39-59.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .chronus/changes/uri-templates-2024-7-6-16-39-59.md 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 From 5522c8177e96f82f60de36b88d382a4e2b7134fc Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 6 Aug 2024 10:37:02 -0700 Subject: [PATCH 25/25] Regen docs --- docs/standard-library/built-in-data-types.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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