diff --git a/.chronus/changes/cookie-decorator-2024-9-22-1-33-4.md b/.chronus/changes/cookie-decorator-2024-9-22-1-33-4.md new file mode 100644 index 0000000000..58b77537a6 --- /dev/null +++ b/.chronus/changes/cookie-decorator-2024-9-22-1-33-4.md @@ -0,0 +1,9 @@ +--- +changeKind: feature +packages: + - "@typespec/http-server-javascript" + - "@typespec/http" + - "@typespec/openapi3" +--- + +Add `@cookie` decorator to specify cookie parameters \ No newline at end of file diff --git a/packages/http-server-javascript/src/http/server/index.ts b/packages/http-server-javascript/src/http/server/index.ts index 6db97cd245..9ab47195b6 100644 --- a/packages/http-server-javascript/src/http/server/index.ts +++ b/packages/http-server-javascript/src/http/server/index.ts @@ -115,6 +115,8 @@ function* emitRawServerOperation( case "header": yield* indent(emitHeaderParamBinding(ctx, parameter)); break; + case "cookie": + throw new UnimplementedError("cookie parameters"); case "query": queryParams.push(parameter); parsedParams.add(resolvedParameter); diff --git a/packages/http/README.md b/packages/http/README.md index 2283e4ff23..5b2d406b14 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -37,6 +37,7 @@ Available ruleSets: - [`@body`](#@body) - [`@bodyIgnore`](#@bodyignore) - [`@bodyRoot`](#@bodyroot) +- [`@cookie`](#@cookie) - [`@delete`](#@delete) - [`@get`](#@get) - [`@head`](#@head) @@ -145,6 +146,47 @@ op download(): { }; ``` +#### `@cookie` + +Specify this property is to be sent or received in the cookie. + +```typespec +@TypeSpec.Http.cookie(cookieNameOrOptions?: valueof string | TypeSpec.Http.CookieOptions) +``` + +##### Target + +`ModelProperty` + +##### Parameters + +| Name | Type | Description | +| ------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| cookieNameOrOptions | `valueof string \| TypeSpec.Http.CookieOptions` | Optional name of the cookie in the cookie or cookie options.
By default the cookie name will be the property name converted from camelCase to snake_case. (e.g. `authToken` -> `auth_token`) | + +##### Examples + +```typespec +op read(@cookie token: string): { + data: string[]; +}; +op create( + @cookie({ + name: "auth_token", + }) + data: string[], +): void; +``` + +###### Implicit header name + +```typespec +op read(): { + @cookie authToken: string; +}; // headerName: auth_token +op update(@cookie AuthToken: string): void; // headerName: auth_token +``` + #### `@delete` Specify the HTTP verb for the target operation to be `DELETE`. diff --git a/packages/http/generated-defs/TypeSpec.Http.ts b/packages/http/generated-defs/TypeSpec.Http.ts index 75eb5e9a59..d8a20e1ffe 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts @@ -7,6 +7,10 @@ import type { Type, } from "@typespec/compiler"; +export interface CookieOptions { + readonly name?: string; +} + export interface QueryOptions { readonly name?: string; readonly explode?: boolean; @@ -73,6 +77,29 @@ export type HeaderDecorator = ( headerNameOrOptions?: Type, ) => void; +/** + * Specify this property is to be sent or received in the cookie. + * + * @param cookieNameOrOptions Optional name of the cookie in the cookie or cookie options. + * By default the cookie name will be the property name converted from camelCase to snake_case. (e.g. `authToken` -> `auth_token`) + * @example + * ```typespec + * op read(@cookie token: string): {data: string[]}; + * op create(@cookie({name: "auth_token"}) data: string[]): void; + * ``` + * @example Implicit header name + * + * ```typespec + * op read(): {@cookie authToken: string}; // headerName: auth_token + * op update(@cookie AuthToken: string): void; // headerName: auth_token + * ``` + */ +export type CookieDecorator = ( + context: DecoratorContext, + target: ModelProperty, + cookieNameOrOptions?: string | CookieOptions, +) => void; + /** * Specify this property is to be sent as a query parameter. * @@ -328,6 +355,7 @@ export type TypeSpecHttpDecorators = { statusCode: StatusCodeDecorator; body: BodyDecorator; header: HeaderDecorator; + cookie: CookieDecorator; query: QueryDecorator; path: PathDecorator; bodyRoot: BodyRootDecorator; diff --git a/packages/http/lib/decorators.tsp b/packages/http/lib/decorators.tsp index 2a969214a9..4f7b1b4ea9 100644 --- a/packages/http/lib/decorators.tsp +++ b/packages/http/lib/decorators.tsp @@ -39,6 +39,38 @@ model HeaderOptions { */ extern dec header(target: ModelProperty, headerNameOrOptions?: string | HeaderOptions); +/** + * Cookie Options. + */ +model CookieOptions { + /** + * Name in the cookie. + */ + name?: string; +} + +/** + * Specify this property is to be sent or received in the cookie. + * + * @param cookieNameOrOptions Optional name of the cookie in the cookie or cookie options. + * By default the cookie name will be the property name converted from camelCase to snake_case. (e.g. `authToken` -> `auth_token`) + * + * @example + * + * ```typespec + * op read(@cookie token: string): {data: string[]}; + * op create(@cookie({name: "auth_token"}) data: string[]): void; + * ``` + * + * @example Implicit header name + * + * ```typespec + * op read(): {@cookie authToken: string}; // headerName: auth_token + * op update(@cookie AuthToken: string): void; // headerName: auth_token + * ``` + */ +extern dec cookie(target: ModelProperty, cookieNameOrOptions?: valueof string | CookieOptions); + /** * Query parameter options. */ diff --git a/packages/http/src/decorators.ts b/packages/http/src/decorators.ts index d052a81c7f..165b4f8054 100644 --- a/packages/http/src/decorators.ts +++ b/packages/http/src/decorators.ts @@ -26,6 +26,8 @@ import { BodyDecorator, BodyIgnoreDecorator, BodyRootDecorator, + CookieDecorator, + CookieOptions, DeleteDecorator, GetDecorator, HeadDecorator, @@ -49,6 +51,7 @@ import { getStatusCodesFromType } from "./status-codes.js"; import { Authentication, AuthenticationOption, + CookieParameterOptions, HeaderFieldOptions, HttpAuth, HttpStatusCodeRange, @@ -122,6 +125,47 @@ export function isHeader(program: Program, entity: Type) { return program.stateMap(HttpStateKeys.header).has(entity); } +/** {@inheritDoc CookieDecorator } */ +export const $cookie: CookieDecorator = ( + context: DecoratorContext, + entity: ModelProperty, + cookieNameOrOptions?: string | CookieOptions, +) => { + const paramName = + typeof cookieNameOrOptions === "string" + ? cookieNameOrOptions + : (cookieNameOrOptions?.name ?? + entity.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()); + const options: CookieParameterOptions = { + type: "cookie", + name: paramName, + }; + context.program.stateMap(HttpStateKeys.cookie).set(entity, options); +}; + +/** + * Get the cookie parameter options for the given entity. + * @param program + * @param entity + * @returns The cookie parameter options or undefined if the entity is not a cookie parameter. + */ +export function getCookieParamOptions( + program: Program, + entity: Type, +): QueryParameterOptions | undefined { + return program.stateMap(HttpStateKeys.cookie).get(entity); +} + +/** + * Check whether the given entity is a cookie parameter. + * @param program + * @param entity + * @returns True if the entity is a cookie parameter, false otherwise. + */ +export function isCookieParam(program: Program, entity: Type): boolean { + return program.stateMap(HttpStateKeys.cookie).has(entity); +} + export const $query: QueryDecorator = ( context: DecoratorContext, entity: ModelProperty, diff --git a/packages/http/src/http-property.ts b/packages/http/src/http-property.ts index e82b2ee8d7..e44da5575e 100644 --- a/packages/http/src/http-property.ts +++ b/packages/http/src/http-property.ts @@ -10,6 +10,7 @@ import { type Program, } from "@typespec/compiler"; import { + getCookieParamOptions, getHeaderFieldOptions, getPathParamOptions, getQueryParamOptions, @@ -20,10 +21,16 @@ import { } from "./decorators.js"; import { createDiagnostic } from "./lib.js"; import { Visibility, isVisible } from "./metadata.js"; -import { HeaderFieldOptions, PathParameterOptions, QueryParameterOptions } from "./types.js"; +import { + CookieParameterOptions, + HeaderFieldOptions, + PathParameterOptions, + QueryParameterOptions, +} from "./types.js"; export type HttpProperty = | HeaderProperty + | CookieProperty | ContentTypeProperty | QueryProperty | PathProperty @@ -44,6 +51,11 @@ export interface HeaderProperty extends HttpPropertyBase { readonly options: HeaderFieldOptions; } +export interface CookieProperty extends HttpPropertyBase { + readonly kind: "cookie"; + readonly options: CookieParameterOptions; +} + export interface ContentTypeProperty extends HttpPropertyBase { readonly kind: "contentType"; } @@ -96,6 +108,7 @@ function getHttpProperty( const annotations = { header: getHeaderFieldOptions(program, property), + cookie: getCookieParamOptions(program, property), query: getQueryParamOptions(program, property), path: getPathParamOptions(program, property), body: isBody(program, property), @@ -174,6 +187,8 @@ function getHttpProperty( } else { return createResult({ kind: "header", options: annotations.header }); } + } else if (annotations.cookie) { + return createResult({ kind: "cookie", options: annotations.cookie }); } else if (annotations.query) { return createResult({ kind: "query", options: annotations.query }); } else if (annotations.path) { @@ -225,6 +240,19 @@ export function resolvePayloadProperties( httpProperty = { kind: "bodyProperty", property, path: propPath }; } + // Ignore cookies in response to avoid future breaking changes to @cookie. + // https://github.com/microsoft/typespec/pull/4761#discussion_r1805082132 + if (httpProperty.kind === "cookie" && visibility & Visibility.Read) { + diagnostics.add( + createDiagnostic({ + code: "response-cookie-not-supported", + target: property, + format: { propName: property.name }, + }), + ); + continue; + } + if ( httpProperty.kind === "body" || httpProperty.kind === "bodyRoot" || diff --git a/packages/http/src/lib.ts b/packages/http/src/lib.ts index b4102a819e..235c2412a4 100644 --- a/packages/http/src/lib.ts +++ b/packages/http/src/lib.ts @@ -100,6 +100,12 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`${"kind"} property will be ignored as it is inside of a @body property. Use @bodyRoot instead if wanting to mix.`, }, }, + "response-cookie-not-supported": { + severity: "warning", + messages: { + default: paramMessage`@cookie on response is not supported. Property '${"propName"}' will be ignored in the body. If you need 'Set-Cookie', use @header instead.`, + }, + }, "no-service-found": { severity: "warning", messages: { @@ -170,6 +176,7 @@ export const $lib = createTypeSpecLibrary({ state: { authentication: { description: "State for the @auth decorator" }, header: { description: "State for the @header decorator" }, + cookie: { description: "State for the @cookie decorator" }, query: { description: "State for the @query decorator" }, path: { description: "State for the @path decorator" }, body: { description: "State for the @body decorator" }, diff --git a/packages/http/src/metadata.ts b/packages/http/src/metadata.ts index 1c0de783cb..093f22407b 100644 --- a/packages/http/src/metadata.ts +++ b/packages/http/src/metadata.ts @@ -16,6 +16,7 @@ import { isBody, isBodyIgnore, isBodyRoot, + isCookieParam, isHeader, isMultipartBodyProperty, isPathParam, @@ -219,11 +220,12 @@ export function resolveRequestVisibility( /** * Determines if a property is metadata. A property is defined to be - * metadata if it is marked `@header`, `@query`, `@path`, or `@statusCode`. + * metadata if it is marked `@header`, `@cookie`, `@query`, `@path`, or `@statusCode`. */ export function isMetadata(program: Program, property: ModelProperty) { return ( isHeader(program, property) || + isCookieParam(program, property) || isQueryParam(program, property) || isPathParam(program, property) || isStatusCode(program, property) diff --git a/packages/http/src/parameters.ts b/packages/http/src/parameters.ts index 61603f7b02..b41de87acb 100644 --- a/packages/http/src/parameters.ts +++ b/packages/http/src/parameters.ts @@ -126,6 +126,7 @@ function getOperationParametersForVerb( } // eslint-disable-next-line no-fallthrough case "query": + case "cookie": case "header": parameters.push({ ...item.options, diff --git a/packages/http/src/payload.ts b/packages/http/src/payload.ts index 441195fe81..c647bff792 100644 --- a/packages/http/src/payload.ts +++ b/packages/http/src/payload.ts @@ -15,7 +15,7 @@ import { } from "@typespec/compiler"; import { DuplicateTracker } from "@typespec/compiler/utils"; import { getContentTypes } from "./content-types.js"; -import { isHeader, isPathParam, isQueryParam, isStatusCode } from "./decorators.js"; +import { isCookieParam, isHeader, isPathParam, isQueryParam, isStatusCode } from "./decorators.js"; import { GetHttpPropertyOptions, HeaderProperty, @@ -259,13 +259,16 @@ function validateBodyProperty( modelProperty: (prop) => { const kind = isHeader(program, prop) ? "header" - : (usedIn === "request" || usedIn === "multipart") && isQueryParam(program, prop) - ? "query" - : usedIn === "request" && isPathParam(program, prop) - ? "path" - : usedIn === "response" && isStatusCode(program, prop) - ? "statusCode" - : undefined; + : // also emit metadata-ignored for response cookie + (usedIn === "request" || usedIn === "response") && isCookieParam(program, prop) + ? "cookie" + : (usedIn === "request" || usedIn === "multipart") && isQueryParam(program, prop) + ? "query" + : usedIn === "request" && isPathParam(program, prop) + ? "path" + : usedIn === "response" && isStatusCode(program, prop) + ? "statusCode" + : undefined; if (kind) { diagnostics.add( diff --git a/packages/http/src/tsp-index.ts b/packages/http/src/tsp-index.ts index 11c34db444..c7314615e6 100644 --- a/packages/http/src/tsp-index.ts +++ b/packages/http/src/tsp-index.ts @@ -3,6 +3,7 @@ import { $body, $bodyIgnore, $bodyRoot, + $cookie, $delete, $get, $head, @@ -30,6 +31,7 @@ export const $decorators = { body: $body, bodyIgnore: $bodyIgnore, bodyRoot: $bodyRoot, + cookie: $cookie, delete: $delete, get: $get, header: $header, diff --git a/packages/http/src/types.ts b/packages/http/src/types.ts index 6bb7989f96..f2076ab15a 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, QueryOptions } from "../generated-defs/TypeSpec.Http.js"; +import { CookieOptions, PathOptions, QueryOptions } from "../generated-defs/TypeSpec.Http.js"; import { HeaderProperty, HttpProperty } from "./http-property.js"; /** @@ -299,6 +299,11 @@ export interface HeaderFieldOptions { format?: "csv" | "multi" | "ssv" | "tsv" | "pipes" | "simple" | "form"; } +export interface CookieParameterOptions extends Required { + type: "cookie"; + name: string; +} + export interface QueryParameterOptions extends Required> { type: "query"; /** @@ -313,12 +318,16 @@ export interface PathParameterOptions extends Required { export type HttpOperationParameter = | HttpOperationHeaderParameter + | HttpOperationCookieParameter | HttpOperationQueryParameter | HttpOperationPathParameter; export type HttpOperationHeaderParameter = HeaderFieldOptions & { param: ModelProperty; }; +export type HttpOperationCookieParameter = CookieParameterOptions & { + param: ModelProperty; +}; export type HttpOperationQueryParameter = QueryParameterOptions & { param: ModelProperty; }; diff --git a/packages/http/test/http-decorators.test.ts b/packages/http/test/http-decorators.test.ts index 6b8bb03621..a54cedfd9d 100644 --- a/packages/http/test/http-decorators.test.ts +++ b/packages/http/test/http-decorators.test.ts @@ -8,6 +8,7 @@ import { deepStrictEqual, ok, strictEqual } from "assert"; import { beforeEach, describe, expect, it } from "vitest"; import { getAuthentication, + getCookieParamOptions, getHeaderFieldName, getHeaderFieldOptions, getPathParamName, @@ -20,6 +21,7 @@ import { isBody, isBodyIgnore, isBodyRoot, + isCookieParam, isHeader, isPathParam, isQueryParam, @@ -132,6 +134,74 @@ describe("http: decorators", () => { }); }); + describe("@cookie", () => { + it("emit diagnostics when @cookie is not used on model property", async () => { + const diagnostics = await runner.diagnose(` + @cookie op test(): string; + + @cookie model Foo {} + `); + + expectDiagnostics(diagnostics, [ + { + code: "decorator-wrong-target", + message: + "Cannot apply @cookie decorator to test since it is not assignable to ModelProperty", + }, + { + code: "decorator-wrong-target", + message: + "Cannot apply @cookie decorator to Foo since it is not assignable to ModelProperty", + }, + ]); + }); + + it("emit diagnostics when cookie name is not a string or of type CookieOptions", async () => { + const diagnostics = await runner.diagnose(` + op test(@cookie(123) MyCookie: string): string; + op test2(@cookie(#{ name: 123 }) MyCookie: string): string; + op test3(@cookie(#{ format: "invalid" }) MyCookie: string): string; + `); + + expectDiagnostics(diagnostics, [ + { + code: "invalid-argument", + }, + { + code: "invalid-argument", + }, + { + code: "invalid-argument", + }, + ]); + }); + + it("generate cookie name from property name", async () => { + const { myCookie } = await runner.compile(` + op test(@test @cookie myCookie: string): string; + `); + + ok(isCookieParam(runner.program, myCookie)); + strictEqual(getCookieParamOptions(runner.program, myCookie)?.name, "my_cookie"); + }); + + it("override cookie name with 1st parameter", async () => { + const { myCookie } = await runner.compile(` + op test(@test @cookie("my-cookie") myCookie: string): string; + `); + + strictEqual(getCookieParamOptions(runner.program, myCookie)?.name, "my-cookie"); + }); + + it("override cookie with CookieOptions", async () => { + const { myCookie } = await runner.compile(` + op test(@test @cookie(#{name: "my-cookie"}) myCookie: string): string; + `); + + strictEqual(getCookieParamOptions(runner.program, myCookie)?.name, "my-cookie"); + }); + }); + describe("@query", () => { it("emit diagnostics when @query is not used on model property", async () => { const diagnostics = await runner.diagnose(` diff --git a/packages/http/test/parameters.test.ts b/packages/http/test/parameters.test.ts index ef3ab3a8c6..79b460d629 100644 --- a/packages/http/test/parameters.test.ts +++ b/packages/http/test/parameters.test.ts @@ -210,6 +210,7 @@ it("resolves unannotated path parameters that are included in the route path", a describe("emit diagnostics when using metadata decorator in @body", () => { it.each([ ["@header", "id: string"], + ["@cookie", "id: string"], ["@query", "id: string"], ["@path", "id: string"], ])("%s", async (dec, prop) => { diff --git a/packages/http/test/responses.test.ts b/packages/http/test/responses.test.ts index 68d6ad3d6b..99a23376ea 100644 --- a/packages/http/test/responses.test.ts +++ b/packages/http/test/responses.test.ts @@ -64,6 +64,24 @@ describe("body resolution", () => { }); }); +describe("response cookie", () => { + it("emit diagnostics for implicit @cookie in the response", async () => { + const [_, diagnostics] = await compileOperations(` + op get(): { @cookie token: string }; + `); + + expectDiagnostics(diagnostics, { code: "@typespec/http/response-cookie-not-supported" }); + }); + + it("doesn't emit response-cookie-not-supported diagnostics for explicit @cookie in the response", async () => { + const [_, diagnostics] = await compileOperations(` + op get(): { @body explicit: { @cookie token: string } }; + `); + + expectDiagnostics(diagnostics, { code: "@typespec/http/metadata-ignored" }); + }); +}); + it("doesn't emit diagnostic if the metadata is not applicable in the response", async () => { const [_, diagnostics] = await compileOperations( `op read(): { @body explicit: {@path id: string} };`, diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 84f595fc3d..ff48b979fa 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1413,6 +1413,10 @@ function createOAPIEmitter( switch (parameter.type) { case "header": return mapHeaderParameterFormat(parameter); + case "cookie": + // style and explode options are omitted from cookies + // https://github.com/microsoft/typespec/pull/4761#discussion_r1803365689 + return { explode: false }; case "query": return getQueryParameterAttributes(parameter); case "path": diff --git a/packages/openapi3/test/metadata.test.ts b/packages/openapi3/test/metadata.test.ts index aa108d9085..de877882cb 100644 --- a/packages/openapi3/test/metadata.test.ts +++ b/packages/openapi3/test/metadata.test.ts @@ -610,6 +610,7 @@ describe("openapi3: metadata", () => { @query q: string; @path p: string; @header h: string; + @cookie c: string; } @route("/single") @get op single(...Parameters): string; @route("/batch") @get op batch(@bodyRoot _: Parameters[]): string; @@ -623,6 +624,7 @@ describe("openapi3: metadata", () => { { $ref: "#/components/parameters/Parameters.q" }, { $ref: "#/components/parameters/Parameters.p" }, { $ref: "#/components/parameters/Parameters.h" }, + { $ref: "#/components/parameters/Parameters.c" }, ], responses: { "200": { @@ -677,10 +679,20 @@ describe("openapi3: metadata", () => { required: true, schema: { type: "string" }, }, + "Parameters.c": { + name: "c", + in: "cookie", + explode: false, + required: true, + schema: { type: "string" }, + }, }, schemas: { Parameters: { properties: { + c: { + type: "string", + }, h: { type: "string", }, @@ -691,7 +703,7 @@ describe("openapi3: metadata", () => { type: "string", }, }, - required: ["q", "p", "h"], + required: ["q", "p", "h", "c"], type: "object", }, }, @@ -704,6 +716,7 @@ describe("openapi3: metadata", () => { @route("/test") @post op test( @query q: string; @header h: string; + @cookie c: string; foo: string; bar: int32; ): string; @@ -727,6 +740,13 @@ describe("openapi3: metadata", () => { required: true, schema: { type: "string" }, }, + { + name: "c", + in: "cookie", + required: true, + explode: false, + schema: { type: "string" }, + }, ], responses: { "200": { @@ -766,6 +786,7 @@ describe("openapi3: metadata", () => { @query q: string; @path p: string; @header h: string; + @cookie c: string; } @route("/batch") @post op batch(@bodyRoot body?: Parameters[]): string; `, @@ -799,6 +820,9 @@ describe("openapi3: metadata", () => { schemas: { Parameters: { properties: { + c: { + type: "string", + }, h: { type: "string", }, @@ -809,7 +833,7 @@ describe("openapi3: metadata", () => { type: "string", }, }, - required: ["q", "p", "h"], + required: ["q", "p", "h", "c"], type: "object", }, }, diff --git a/packages/openapi3/test/parameters.test.ts b/packages/openapi3/test/parameters.test.ts index d813a4bfa8..4f7454f78a 100644 --- a/packages/openapi3/test/parameters.test.ts +++ b/packages/openapi3/test/parameters.test.ts @@ -240,6 +240,27 @@ describe("query parameters", () => { }); }); + it("create a cookie param", async () => { + const res = await openApiFor( + ` + op test(@cookie arg1: string): void; + `, + ); + strictEqual(res.paths["/"].get.parameters[0].in, "cookie"); + strictEqual(res.paths["/"].get.parameters[0].name, "arg1"); + deepStrictEqual(res.paths["/"].get.parameters[0].schema, { type: "string" }); + }); + + it("create a cookie param with a different name", async () => { + const res = await openApiFor( + ` + op test(@cookie("foo_bar") foo: string): void; + `, + ); + strictEqual(res.paths["/"].get.parameters[0].in, "cookie"); + strictEqual(res.paths["/"].get.parameters[0].name, "foo_bar"); + }); + // Regression test for https://github.com/microsoft/typespec/issues/414 it("@doc set the description on the parameter not its schema", async () => { const res = await openApiFor( @@ -347,6 +368,8 @@ describe("query parameters", () => { #suppress "@typespec/http/metadata-ignored" @header header: string, #suppress "@typespec/http/metadata-ignored" + @cookie cookie: string, + #suppress "@typespec/http/metadata-ignored" @query query: string, #suppress "@typespec/http/metadata-ignored" @statusCode code: 201, @@ -356,10 +379,11 @@ describe("query parameters", () => { type: "object", properties: { header: { type: "string" }, + cookie: { type: "string" }, query: { type: "string" }, code: { type: "number", enum: [201] }, }, - required: ["header", "query", "code"], + required: ["header", "cookie", "query", "code"], }); }); @@ -379,15 +403,20 @@ describe("query parameters", () => { @header header1: string; @header header2: string; }; + cookies: { + @cookie cookie1: string; + @cookie cookie2: string; + }; name: string; ): void;`); expect(res.paths["/"].post.requestBody.content["application/json"].schema).toEqual({ type: "object", properties: { headers: { type: "object" }, + cookies: { type: "object" }, name: { type: "string" }, }, - required: ["headers", "name"], + required: ["headers", "cookies", "name"], }); }); @@ -397,6 +426,10 @@ describe("query parameters", () => { @header header1: string; @header header2: string; }; + @bodyIgnore cookies: { + @cookie cookie1: string; + @cookie cookie2: string; + }; name: string; ): void;`); expect(res.paths["/"].post.requestBody.content["application/json"].schema).toEqual({ diff --git a/packages/openapi3/test/return-types.test.ts b/packages/openapi3/test/return-types.test.ts index 9cfe47b3b8..d647cde7b2 100644 --- a/packages/openapi3/test/return-types.test.ts +++ b/packages/openapi3/test/return-types.test.ts @@ -435,6 +435,8 @@ describe("openapi3: return types", () => { #suppress "@typespec/http/metadata-ignored" @header header: string, #suppress "@typespec/http/metadata-ignored" + @cookie cookie: string, + #suppress "@typespec/http/metadata-ignored" @query query: string, #suppress "@typespec/http/metadata-ignored" @statusCode code: 201, @@ -444,10 +446,11 @@ describe("openapi3: return types", () => { type: "object", properties: { header: { type: "string" }, + cookie: { type: "string" }, query: { type: "string" }, code: { type: "number", enum: [201] }, }, - required: ["header", "query", "code"], + required: ["header", "cookie", "query", "code"], }); }); @@ -496,6 +499,24 @@ describe("openapi3: return types", () => { }); }); + it("invalid metadata properties in body should still be included but response cookies should not be included", async () => { + const res = await openApiFor(`op read(): { + @header header: string; + #suppress "@typespec/http/response-cookie-not-supported" + @cookie cookie: string; + @query query: string; + name: string; + };`); + expect(res.paths["/"].get.responses["200"].content["application/json"].schema).toEqual({ + type: "object", + properties: { + query: { type: "string" }, + name: { type: "string" }, + }, + required: ["query", "name"], + }); + }); + describe("multiple content types", () => { it("handles multiple content types for the same status code", async () => { const res = await openApiFor( diff --git a/website/src/content/docs/docs/libraries/http/reference/data-types.md b/website/src/content/docs/docs/libraries/http/reference/data-types.md index 8cfee7d5a5..3e2a068c57 100644 --- a/website/src/content/docs/docs/libraries/http/reference/data-types.md +++ b/website/src/content/docs/docs/libraries/http/reference/data-types.md @@ -1,5 +1,7 @@ --- title: "Data types" +toc_min_heading_level: 2 +toc_max_heading_level: 3 --- ## TypeSpec.Http @@ -187,6 +189,20 @@ model TypeSpec.Http.ConflictResponse | ---------- | ----- | ---------------- | | statusCode | `409` | The status code. | +### `CookieOptions` {#TypeSpec.Http.CookieOptions} + +Cookie Options. + +```typespec +model TypeSpec.Http.CookieOptions +``` + +#### Properties + +| Name | Type | Description | +| ----- | -------- | ------------------- | +| name? | `string` | Name in the cookie. | + ### `CreatedResponse` {#TypeSpec.Http.CreatedResponse} The request has succeeded and a new resource has been created as a result. diff --git a/website/src/content/docs/docs/libraries/http/reference/decorators.md b/website/src/content/docs/docs/libraries/http/reference/decorators.md index bc5785cfac..ee18e8266b 100644 --- a/website/src/content/docs/docs/libraries/http/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/http/reference/decorators.md @@ -97,6 +97,47 @@ op download(): { }; ``` +### `@cookie` {#@TypeSpec.Http.cookie} + +Specify this property is to be sent or received in the cookie. + +```typespec +@TypeSpec.Http.cookie(cookieNameOrOptions?: valueof string | TypeSpec.Http.CookieOptions) +``` + +#### Target + +`ModelProperty` + +#### Parameters + +| Name | Type | Description | +| ------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| cookieNameOrOptions | `valueof string \| TypeSpec.Http.CookieOptions` | Optional name of the cookie in the cookie or cookie options.
By default the cookie name will be the property name converted from camelCase to snake_case. (e.g. `authToken` -> `auth_token`) | + +#### Examples + +```typespec +op read(@cookie token: string): { + data: string[]; +}; +op create( + @cookie({ + name: "auth_token", + }) + data: string[], +): void; +``` + +##### Implicit header name + +```typespec +op read(): { + @cookie authToken: string; +}; // headerName: auth_token +op update(@cookie AuthToken: string): void; // headerName: auth_token +``` + ### `@delete` {#@TypeSpec.Http.delete} Specify the HTTP verb for the target operation to be `DELETE`. diff --git a/website/src/content/docs/docs/libraries/http/reference/index.mdx b/website/src/content/docs/docs/libraries/http/reference/index.mdx index 865df0b23c..4eb94114d9 100644 --- a/website/src/content/docs/docs/libraries/http/reference/index.mdx +++ b/website/src/content/docs/docs/libraries/http/reference/index.mdx @@ -36,6 +36,7 @@ npm install --save-peer @typespec/http - [`@body`](./decorators.md#@TypeSpec.Http.body) - [`@bodyIgnore`](./decorators.md#@TypeSpec.Http.bodyIgnore) - [`@bodyRoot`](./decorators.md#@TypeSpec.Http.bodyRoot) +- [`@cookie`](./decorators.md#@TypeSpec.Http.cookie) - [`@delete`](./decorators.md#@TypeSpec.Http.delete) - [`@get`](./decorators.md#@TypeSpec.Http.get) - [`@head`](./decorators.md#@TypeSpec.Http.head) @@ -64,6 +65,7 @@ npm install --save-peer @typespec/http - [`Body`](./data-types.md#TypeSpec.Http.Body) - [`ClientCredentialsFlow`](./data-types.md#TypeSpec.Http.ClientCredentialsFlow) - [`ConflictResponse`](./data-types.md#TypeSpec.Http.ConflictResponse) +- [`CookieOptions`](./data-types.md#TypeSpec.Http.CookieOptions) - [`CreatedResponse`](./data-types.md#TypeSpec.Http.CreatedResponse) - [`File`](./data-types.md#TypeSpec.Http.File) - [`ForbiddenResponse`](./data-types.md#TypeSpec.Http.ForbiddenResponse) diff --git a/website/src/content/docs/docs/libraries/http/reference/linter.md b/website/src/content/docs/docs/libraries/http/reference/linter.md index 3b4e99e7f4..5440e045d1 100644 --- a/website/src/content/docs/docs/libraries/http/reference/linter.md +++ b/website/src/content/docs/docs/libraries/http/reference/linter.md @@ -1,5 +1,7 @@ --- title: "Linter usage" +toc_min_heading_level: 2 +toc_max_heading_level: 3 --- ## Usage