diff --git a/.chronus/changes/array-encoding-comma-newline-2025-11-13-20-53-33.md b/.chronus/changes/array-encoding-comma-newline-2025-11-13-20-53-33.md new file mode 100644 index 00000000000..2f6c1a3c6c7 --- /dev/null +++ b/.chronus/changes/array-encoding-comma-newline-2025-11-13-20-53-33.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" + - "@typespec/openapi3" +--- + +Add `commaDelimited` and `newlineDelimited` values to `ArrayEncoding` enum for serializing arrays with comma and newline delimiters diff --git a/cspell.yaml b/cspell.yaml index b555dc79a7d..3d5a5009cdb 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -13,6 +13,7 @@ words: - AQID - Arize - arizeaiobservabilityeval + - Ablack - arraya - astimezone - astro @@ -35,6 +36,8 @@ words: - cadl - cadleditor - cadleng + - Cblack + - Cbrown - cadlplayground - canonicalizer - clsx diff --git a/packages/compiler/lib/std/decorators.tsp b/packages/compiler/lib/std/decorators.tsp index 840579fa438..0c35a010e26 100644 --- a/packages/compiler/lib/std/decorators.tsp +++ b/packages/compiler/lib/std/decorators.tsp @@ -531,11 +531,37 @@ enum BytesKnownEncoding { * Encoding for serializing arrays */ enum ArrayEncoding { - /** Each values of the array is separated by a | */ + /** + * Each value of the array is separated by a pipe character (|). + * Values can only contain | if the underlying protocol supports encoding them. + * - json -> error + * - http -> %7C + */ pipeDelimited, - /** Each values of the array is separated by a */ + /** + * Each value of the array is separated by a space character. + * Values can only contain spaces if the underlying protocol supports encoding them. + * - json -> error + * - http -> %20 + */ spaceDelimited, + + /** + * Each value of the array is separated by a comma (,). + * Values can only contain commas if the underlying protocol supports encoding them. + * - json -> error + * - http -> %2C + */ + commaDelimited, + + /** + * Each value of the array is separated by a newline character (\n). + * Values can only contain newlines if the underlying protocol supports encoding them. + * - json -> error + * - http -> %0A + */ + newlineDelimited, } /** diff --git a/packages/compiler/test/decorators/decorators.test.ts b/packages/compiler/test/decorators/decorators.test.ts index 648283e2901..111972c7e49 100644 --- a/packages/compiler/test/decorators/decorators.test.ts +++ b/packages/compiler/test/decorators/decorators.test.ts @@ -32,7 +32,9 @@ import { createTestRunner, expectDiagnosticEmpty, expectDiagnostics, + t, } from "../../src/testing/index.js"; +import { Tester } from "../tester.js"; describe("compiler: built-in decorators", () => { let runner: BasicTestRunner; @@ -795,6 +797,52 @@ describe("compiler: built-in decorators", () => { }); }); }); + + describe("ArrayEncoding enum", () => { + it("can use ArrayEncoding.pipeDelimited", async () => { + const { prop, program } = await Tester.compile(t.code` + model Foo { + @encode(ArrayEncoding.pipeDelimited) + ${t.modelProperty("prop")}: string[]; + } + `); + + strictEqual(getEncode(program, prop)?.encoding, "ArrayEncoding.pipeDelimited"); + }); + + it("can use ArrayEncoding.spaceDelimited", async () => { + const { prop, program } = await Tester.compile(t.code` + model Foo { + @encode(ArrayEncoding.spaceDelimited) + ${t.modelProperty("prop")}: string[]; + } + `); + + strictEqual(getEncode(program, prop)?.encoding, "ArrayEncoding.spaceDelimited"); + }); + + it("can use ArrayEncoding.commaDelimited", async () => { + const { prop, program } = await Tester.compile(t.code` + model Foo { + @encode(ArrayEncoding.commaDelimited) + ${t.modelProperty("prop")}: string[]; + } + `); + + strictEqual(getEncode(program, prop)?.encoding, "ArrayEncoding.commaDelimited"); + }); + + it("can use ArrayEncoding.newlineDelimited", async () => { + const { prop, program } = await Tester.compile(t.code` + model Foo { + @encode(ArrayEncoding.newlineDelimited) + ${t.modelProperty("prop")}: string[]; + } + `); + + strictEqual(getEncode(program, prop)?.encoding, "ArrayEncoding.newlineDelimited"); + }); + }); }); describe("@withoutOmittedProperties", () => { diff --git a/packages/openapi3/src/encoding.ts b/packages/openapi3/src/encoding.ts index acce72d6012..a7e9de5832d 100644 --- a/packages/openapi3/src/encoding.ts +++ b/packages/openapi3/src/encoding.ts @@ -7,7 +7,12 @@ import type { OpenAPI3Schema, OpenAPISchema3_1 } from "./types.js"; function isParameterStyleEncoding(encoding: string | undefined): boolean { if (!encoding) return false; - return ["ArrayEncoding.pipeDelimited", "ArrayEncoding.spaceDelimited"].includes(encoding); + return [ + "ArrayEncoding.pipeDelimited", + "ArrayEncoding.spaceDelimited", + "ArrayEncoding.commaDelimited", + "ArrayEncoding.newlineDelimited", + ].includes(encoding); } export function applyEncoding( diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index d39223e2722..fdc7ced5ab8 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -373,6 +373,10 @@ function getQueryParameterValue( return getParameterDelimitedValue(program, originalValue, property, " "); case "pipeDelimited": return getParameterDelimitedValue(program, originalValue, property, "|"); + case "commaDelimited": + return getParameterDelimitedValue(program, originalValue, property, ","); + case "newlineDelimited": + return getParameterDelimitedValue(program, originalValue, property, "\n"); } } @@ -518,7 +522,7 @@ function getParameterDelimitedValue( program: Program, originalValue: Value, property: Extract, - delimiter: " " | "|", + delimiter: " " | "|" | "," | "\n", ): Value | undefined { const { explode, name } = property.options; // Serialization is undefined for explode=true diff --git a/packages/openapi3/src/parameters.ts b/packages/openapi3/src/parameters.ts index d2c22a753e2..bef5d8e9f1a 100644 --- a/packages/openapi3/src/parameters.ts +++ b/packages/openapi3/src/parameters.ts @@ -3,7 +3,7 @@ import { getEncode, ModelProperty, Program } from "@typespec/compiler"; export function getParameterStyle( program: Program, type: ModelProperty, -): "pipeDelimited" | "spaceDelimited" | undefined { +): "pipeDelimited" | "spaceDelimited" | "commaDelimited" | "newlineDelimited" | undefined { const encode = getEncode(program, type); if (!encode) return; @@ -11,6 +11,10 @@ export function getParameterStyle( return "pipeDelimited"; } else if (encode.encoding === "ArrayEncoding.spaceDelimited") { return "spaceDelimited"; + } else if (encode.encoding === "ArrayEncoding.commaDelimited") { + return "commaDelimited"; + } else if (encode.encoding === "ArrayEncoding.newlineDelimited") { + return "newlineDelimited"; } return; } diff --git a/packages/openapi3/test/examples.test.ts b/packages/openapi3/test/examples.test.ts index eb159fe0a2f..b31fc32b01d 100644 --- a/packages/openapi3/test/examples.test.ts +++ b/packages/openapi3/test/examples.test.ts @@ -439,6 +439,78 @@ worksFor(supportedVersions, ({ openApiFor }) => { paramExample: `#{R: 100, G: 200, B: 150}`, expectedExample: undefined, }, + { + desc: "commaDelimited (undefined)", + param: `@query @encode(ArrayEncoding.commaDelimited) color: string | null`, + paramExample: `null`, + expectedExample: undefined, + }, + { + desc: "commaDelimited (string)", + param: `@query @encode(ArrayEncoding.commaDelimited) color: string`, + paramExample: `"blue"`, + expectedExample: undefined, + }, + { + desc: "commaDelimited (array) explode: false", + param: `@query @encode(ArrayEncoding.commaDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "color=blue%2Cblack%2Cbrown", + }, + { + desc: "commaDelimited (array) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.commaDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: undefined, + }, + { + desc: "commaDelimited (object) explode: false", + param: `@query @encode(ArrayEncoding.commaDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "color=R%2C100%2CG%2C200%2CB%2C150", + }, + { + desc: "commaDelimited (object) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.commaDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: undefined, + }, + { + desc: "newlineDelimited (undefined)", + param: `@query @encode(ArrayEncoding.newlineDelimited) color: string | null`, + paramExample: `null`, + expectedExample: undefined, + }, + { + desc: "newlineDelimited (string)", + param: `@query @encode(ArrayEncoding.newlineDelimited) color: string`, + paramExample: `"blue"`, + expectedExample: undefined, + }, + { + desc: "newlineDelimited (array) explode: false", + param: `@query @encode(ArrayEncoding.newlineDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: "color=blue%0Ablack%0Abrown", + }, + { + desc: "newlineDelimited (array) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.newlineDelimited) color: string[]`, + paramExample: `#["blue", "black", "brown"]`, + expectedExample: undefined, + }, + { + desc: "newlineDelimited (object) explode: false", + param: `@query @encode(ArrayEncoding.newlineDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: "color=R%0A100%0AG%0A200%0AB%0A150", + }, + { + desc: "newlineDelimited (object) explode: true", + param: `@query(#{ explode: true }) @encode(ArrayEncoding.newlineDelimited) color: Record`, + paramExample: `#{R: 100, G: 200, B: 150}`, + expectedExample: undefined, + }, ])("$desc", async ({ param, paramExample, expectedExample }) => { const res = await openApiFor( ` diff --git a/packages/openapi3/test/parameters.test.ts b/packages/openapi3/test/parameters.test.ts index 6ca60fd0f1d..6a861660dc5 100644 --- a/packages/openapi3/test/parameters.test.ts +++ b/packages/openapi3/test/parameters.test.ts @@ -35,6 +35,8 @@ worksFor(supportedVersions, ({ diagnoseOpenApiFor, openApiFor }) => { it.each([ { encoding: "ArrayEncoding.pipeDelimited", style: "pipeDelimited" }, { encoding: "ArrayEncoding.spaceDelimited", style: "spaceDelimited" }, + { encoding: "ArrayEncoding.commaDelimited", style: "commaDelimited" }, + { encoding: "ArrayEncoding.newlineDelimited", style: "newlineDelimited" }, ])("can set style to $style with @encode($encoding)", async ({ encoding, style }) => { const param = await getQueryParam( `op test(@query @encode(${encoding}) myParam: string[]): void;`, diff --git a/website/src/content/docs/docs/standard-library/built-in-data-types.md b/website/src/content/docs/docs/standard-library/built-in-data-types.md index 1fa307bd4d9..663e1bca85d 100644 --- a/website/src/content/docs/docs/standard-library/built-in-data-types.md +++ b/website/src/content/docs/docs/standard-library/built-in-data-types.md @@ -490,8 +490,10 @@ 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 | +| pipeDelimited | | Each value of the array is separated by a pipe character (\|).
Values can only contain \| if the underlying protocol supports encoding them.
- json -> error
- http -> %7C | +| spaceDelimited | | Each value of the array is separated by a space character.
Values can only contain spaces if the underlying protocol supports encoding them.
- json -> error
- http -> %20 | +| commaDelimited | | Each value of the array is separated by a comma (,).
Values can only contain commas if the underlying protocol supports encoding them.
- json -> error
- http -> %2C | +| newlineDelimited | | Each value of the array is separated by a newline character (\n).
Values can only contain newlines if the underlying protocol supports encoding them.
- json -> error
- http -> %0A | ### `BytesKnownEncoding` {#BytesKnownEncoding}