diff --git a/packages/http-client-csharp/emitter/src/lib/operation-converter.ts b/packages/http-client-csharp/emitter/src/lib/operation-converter.ts index 6a1b182add..49e45bca1a 100644 --- a/packages/http-client-csharp/emitter/src/lib/operation-converter.ts +++ b/packages/http-client-csharp/emitter/src/lib/operation-converter.ts @@ -185,6 +185,17 @@ function fromSdkHttpOperationParameter( const parameterType = fromSdkType(p.type, sdkContext, typeMap); const format = p.kind === "header" || p.kind === "query" ? p.collectionFormat : undefined; const serializedName = p.kind !== "body" ? p.serializedName : p.name; + let explode = false; + + // TO-DO: In addition to checking if a path parameter is exploded, we should consider capturing the style for + // any path expansion to ensure the parameter values are delimited correctly during serialization. + if (parameterType.kind === "array" || parameterType.kind === "dict") { + if (format === "multi") { + explode = true; + } else if (isExplodedSdkQueryParameter(p) || isExplodedSdkPathParameter(p)) { + explode = true; + } + } return { Name: p.name, @@ -196,7 +207,7 @@ function fromSdkHttpOperationParameter( p.name.toLocaleLowerCase() === "apiversion" || p.name.toLocaleLowerCase() === "api-version", IsContentType: isContentType, IsEndpoint: false, - Explode: parameterType.kind === "array" && format === "multi" ? true : false, + Explode: explode, ArraySerializationDelimiter: format ? collectionFormatToDelimMap[format] : undefined, IsRequired: !p.optional, Kind: getParameterKind(p, parameterType, rootApiVersions.length > 0), @@ -418,3 +429,11 @@ function normalizeHeaderName(name: string): string { return name; } } + +function isExplodedSdkQueryParameter(p: any): p is SdkQueryParameter { + return (p as SdkQueryParameter).explode === true && (p as SdkQueryParameter).kind === "query"; +} + +function isExplodedSdkPathParameter(p: any): p is SdkPathParameter { + return (p as SdkPathParameter).explode === true && (p as SdkPathParameter).kind === "path"; +} diff --git a/packages/http-client-csharp/emitter/test/Unit/input-parameter.test.ts b/packages/http-client-csharp/emitter/test/Unit/input-parameter.test.ts new file mode 100644 index 0000000000..30a4ea3f07 --- /dev/null +++ b/packages/http-client-csharp/emitter/test/Unit/input-parameter.test.ts @@ -0,0 +1,593 @@ +import { TestHost } from "@typespec/compiler/testing"; +import { strictEqual } from "assert"; +import { beforeEach, describe, it } from "vitest"; +import { createModel } from "../../src/lib/client-model-builder.js"; +import { RequestLocation } from "../../src/type/request-location.js"; +import { + createEmitterContext, + createEmitterTestHost, + createNetSdkContext, + typeSpecCompile, +} from "./utils/test-util.js"; + +describe("Test Parameter Explode", () => { + let runner: TestHost; + + beforeEach(async () => { + runner = await createEmitterTestHost(); + }); + + describe("path parameters", () => { + describe("using simple expansion", () => { + it("is false with primitive parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: "a" + Expected path: /primitivea + """) + @route("primitive{param*}") + op primitive(param: string): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/primitive{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "string"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, false); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with array parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: ["a","b"] + Expected path: /array/a,b + """) + @route("array{param*}") + op array(param: string[]): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/array{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "array"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with record parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: {a: 1, b: 2} + Expected path: /record/a=1,b=2 + """) + @route("record{param*}") + op record(param: Record): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/record{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "dict"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + }); + + describe("using path expansion", () => { + it("is false with primitive parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: "a" + Expected path: /primitive/a + """) + @route("primitive{/param*}") + op primitive(param: string): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/primitive{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "string"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, false); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with array parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: ["a","b"] + Expected path: /array/a/b + """) + @route("array{/param*}") + op array(param: string[]): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/array{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "array"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with record parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: {a: 1, b: 2} + Expected path: /record/a=1/b=2 + """) + @route("record{/param*}") + op record(param: Record): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/record{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "dict"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + }); + + describe("using label expansion", () => { + it("is false with primitive parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: "a" + Expected path: /primitive.a + """) + @route("primitive{.param*}") + op primitive(param: string): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/primitive{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "string"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, false); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with array parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: ["a","b"] + Expected path: /array.a.b + """) + @route("array{.param*}") + op array(param: string[]): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/array{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "array"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with record parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: {a: 1, b: 2} + Expected path: /record.a=1.b=2 + """) + @route("record{.param*}") + op record(param: Record): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/record{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "dict"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + }); + + describe("using matrix expansion", () => { + it("is false with primitive parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: "a" + Expected path: /primitive;param=a + """) + @route("primitive{;param*}") + op primitive(param: string): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/primitive{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "string"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, false); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with array parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: ["a","b"] + Expected path: /array;param=a;param=b + """) + @route("array{;param*}") + op array(param: string[]): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/array{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "array"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with record parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: {a: 1, b: 2} + Expected path: /record;a=1;b=2 + """) + @route("record{;param*}") + op record(param: Record): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/record{param}"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "dict"); + strictEqual(inputParam.Location, RequestLocation.Path); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + }); + }); + + describe("query parameters", () => { + describe("using query expansion", () => { + it("is false with primitive parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: "a" + Expected path: /primitive?param=a + """) + @route("primitive{?param*}") + op primitive(param: string): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/primitive"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "string"); + strictEqual(inputParam.Location, RequestLocation.Query); + strictEqual(inputParam.Explode, false); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with array parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: ["a","b"] + Expected path: /array?param=a¶m=b + """) + @route("array{?param*}") + op array(param: string[]): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/array"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "array"); + strictEqual(inputParam.Location, RequestLocation.Query); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with record parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: {a: 1, b: 2} + Expected path: /record?a=1&b=2 + """) + @route("record{?param*}") + op record(param: Record): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/record"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "dict"); + strictEqual(inputParam.Location, RequestLocation.Query); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + }); + + describe("query continuation", () => { + it("is false with primitive parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: "a" + Expected path: /primitive?fixed=true¶m=a + """) + @route("primitive?fixed=true{¶m*}") + op primitive(param: string): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/primitive?fixed=true"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "string"); + strictEqual(inputParam.Location, RequestLocation.Query); + strictEqual(inputParam.Explode, false); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with array parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: ["a","b"] + Expected path: /array?fixed=true¶m=a¶m=b + """) + @route("array?fixed=true{¶m*}") + op array(param: string[]): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/array?fixed=true"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "array"); + strictEqual(inputParam.Location, RequestLocation.Query); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + + it("is true with record parameter type", async () => { + const program = await typeSpecCompile( + ` + @doc(""" + Param value: {a: 1, b: 2} + Expected path: /record?fixed=true&a=1&b=2 + """) + @route("record?fixed=true{¶m*}") + op record(param: Record): void; + `, + runner, + ); + const context = createEmitterContext(program); + const sdkContext = await createNetSdkContext(context); + const root = createModel(sdkContext); + const inputParamArray = root.Clients[0].Operations[0].Parameters.filter( + (p) => p.Name === "param", + ); + const route = root.Clients[0].Operations[0].Path; + + strictEqual(route, "/record?fixed=true"); + strictEqual(1, inputParamArray.length); + const inputParam = inputParamArray[0]; + const type = inputParam.Type; + + strictEqual(type.kind, "dict"); + strictEqual(inputParam.Location, RequestLocation.Query); + strictEqual(inputParam.Explode, true); + strictEqual(inputParam.ArraySerializationDelimiter, undefined); + }); + }); + }); +}); diff --git a/packages/http-client-csharp/generator/TestProjects/CadlRanch/http/routes/tspCodeModel.json b/packages/http-client-csharp/generator/TestProjects/CadlRanch/http/routes/tspCodeModel.json index fd9d5fbd52..c8457ca5ef 100644 --- a/packages/http-client-csharp/generator/TestProjects/CadlRanch/http/routes/tspCodeModel.json +++ b/packages/http-client-csharp/generator/TestProjects/CadlRanch/http/routes/tspCodeModel.json @@ -743,7 +743,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -804,7 +804,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -1206,7 +1206,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -1267,7 +1267,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -1669,7 +1669,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -1730,7 +1730,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -2132,7 +2132,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -2193,7 +2193,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -2786,7 +2786,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -2847,7 +2847,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -3249,7 +3249,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [], @@ -3310,7 +3310,7 @@ "IsApiVersion": false, "IsContentType": false, "IsEndpoint": false, - "Explode": false, + "Explode": true, "IsRequired": true, "Kind": "Method", "Decorators": [],