diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index f994952b..a5c84765 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -3,6 +3,7 @@ namespace SwaggerProvider.Internal.Compilers open System open System.Collections.Generic open System.Net.Http +open System.Reflection open System.Text.Json open Microsoft.FSharp.Quotations @@ -60,6 +61,44 @@ type PayloadType = /// Object for compiling operations. type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, ignoreControllerPrefix, ignoreOperationId, asAsync: bool) = + let toParamMethod = + match <@@ RuntimeHelpers.toParam(null) @@> with + | Call(None, m, _) -> m + | _ -> failwith "Cannot extract toParam MethodInfo" + + let toQueryParamsMethod = + match <@@ RuntimeHelpers.toQueryParams "" null Unchecked.defaultof @@> with + | Call(None, m, _) -> m + | _ -> failwith "Cannot extract toQueryParams MethodInfo" + + let resolveCastMethod(ownerType: Type) = + ownerType.GetMethods(BindingFlags.Public ||| BindingFlags.Static) + |> Array.tryFind(fun m -> + m.Name = "cast" + && m.IsGenericMethodDefinition + && m.GetGenericArguments().Length = 1 + && m.GetParameters().Length = 1) + |> Option.defaultWith(fun () -> failwithf "Cannot extract %s.cast<'T> MethodInfo" ownerType.FullName) + + let taskCastMethod = resolveCastMethod typeof + let asyncCastMethod = resolveCastMethod typeof + + let stringPairListExpr(items: (string * string) list) : Expr<(string * string) list> = + let empty = <@ [] @> + + (empty, List.rev items) + ||> List.fold(fun acc (name, value) -> + let nameExpr = Expr.Value(name, typeof) |> Expr.Cast + let valueExpr = Expr.Value(value, typeof) |> Expr.Cast + + <@ (%nameExpr, %valueExpr) :: %acc @>) + + let typedListExpr(items: Expr<'T> list) : Expr<'T list> = + let empty = <@ [] @> + + (empty, List.rev items) + ||> List.fold(fun acc item -> <@ %item :: %acc @>) + let compileOperation (providedMethodName: string) (apiCall: ApiCall) = let path, pathItem, opTy = apiCall let operation = pathItem.Operations[opTy] @@ -96,7 +135,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let (|NoMediaType|_|)(content: IDictionary) = if isNull content || content.Count = 0 then Some() else None - let payloadTy, payloadMime, parameters, ctArgIndex = + let payloadTy, payloadMime, parameters, ctArgIndex, apiParamByProvidedName = /// handles de-duplicating Swagger parameter names if the same parameter name /// appears in multiple locations in a given operation definition. let uniqueParamName usedNames (param: IOpenApiParameter) = @@ -156,8 +195,8 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, |> List.partition(_.Required) let buildProvidedParameters usedNames (paramList: IOpenApiParameter list) = - ((usedNames, []), paramList) - ||> List.fold(fun (names, parameters) current -> + ((usedNames, [], []), paramList) + ||> List.fold(fun (names, parameters, lookup) current -> let names, paramName = uniqueParamName names current let paramType = @@ -170,15 +209,20 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let paramDefaultValue = defCompiler.GetDefaultValue paramType ProvidedParameter(paramName, paramType, false, paramDefaultValue) - (names, providedParam :: parameters)) - |> fun (finalNames, ps) -> finalNames, List.rev ps + (names, providedParam :: parameters, (paramName, current) :: lookup)) + |> fun (finalNames, ps, lookup) -> finalNames, List.rev ps, List.rev lookup - let namesAfterRequired, requiredProvidedParams = + let namesAfterRequired, requiredProvidedParams, requiredLookup = buildProvidedParameters Set.empty requiredOpenApiParams - let _, optionalProvidedParams = + let _, optionalProvidedParams, optionalLookup = buildProvidedParameters namesAfterRequired optionalOpenApiParams + let apiParamByProvidedName = + requiredLookup @ optionalLookup + |> List.choose(fun (paramName, param) -> if param.In.HasValue then Some(paramName, param) else None) + |> Map.ofList + let ctArgIndex, parameters = let scope = UniqueNameGenerator() @@ -196,7 +240,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, ctArgIndex, requiredProvidedParams @ optionalProvidedParams @ [ ctParam ] - payloadTy, payloadTy.ToMediaType(), parameters, ctArgIndex + payloadTy, payloadTy.ToMediaType(), parameters, ctArgIndex, apiParamByProvidedName // find the inner type value let okResponse = @@ -258,6 +302,12 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, |> Seq.toArray |> Array.unzip + let fixedHeaders = + [ if not(isNull payloadMime) then + "Content-Type", payloadMime + if not(isNull retMime) then + "Accept", retMime ] + let m = ProvidedMethod( providedMethodName, @@ -271,13 +321,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let httpMethod = opTy.ToString() - let headers = - <@ - [ if not(isNull payloadMime) then - "Content-Type", payloadMime - if not(isNull retMime) then - "Accept", retMime ] - @> + let headers = stringPairListExpr fixedHeaders // Locates parameters matching the arguments let mutable payloadExp = None @@ -298,14 +342,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, apiArgs |> List.choose (function | ShapeVar sVar as expr -> - let param = - openApiParameters - |> Seq.tryFind(fun x -> - // pain point: we have to make sure that the set of names we search for here are the same as the set of names generated when we make `parameters` above - let baseName = niceCamelName x.Name - baseName = sVar.Name || (unambiguousName x) = sVar.Name) - - match param with + match apiParamByProvidedName |> Map.tryFind sVar.Name with | Some(par) -> Some(par, expr) | _ -> let payloadType = PayloadType.Parse sVar.Name @@ -324,20 +361,10 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, // object across all calls, causing "duplicate key" exceptions in ProvidedTypes // when the same helper is called for multiple parameters in one operation. // Instead, build the call expression directly without an intermediate binding. - let toParamMethod = - match <@@ RuntimeHelpers.toParam(null) @@> with - | Call(None, m, _) -> m - | _ -> failwith "Cannot extract toParam MethodInfo" - let coerceString exp = let obj = Expr.Coerce(exp, typeof) Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast - let toQueryParamsMethod = - match <@@ RuntimeHelpers.toQueryParams "" null (%this) @@> with - | Call(None, m, _) -> m - | _ -> failwith "Cannot extract toQueryParams MethodInfo" - let rec coerceQueryString name expr = let obj = Expr.Coerce(expr, typeof) @@ -345,10 +372,10 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, |> Expr.Cast<(string * string) list> // Partitions arguments based on their locations - let path, queryParams, headers = - let path, queryParams, headers, cookies = - ((<@ path @>, <@ [] @>, headers, <@ [] @>), parameters) - ||> List.fold(fun (path, query, headers, cookies) (param: IOpenApiParameter, valueExpr) -> + let path, queryParamLists, headers, cookies = + let path, queryParamLists, headers, cookies = + ((<@ path @>, [], headers, <@ [] @>), parameters) + ||> List.fold(fun (path, queryParamLists, headers, cookies) (param: IOpenApiParameter, valueExpr) -> if param.In.HasValue then let name = param.Name @@ -357,44 +384,32 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let value = coerceString valueExpr let pattern = $"{{%s{name}}}" let path' = <@ (%path).Replace(pattern, %value) @> - (path', query, headers, cookies) + (path', queryParamLists, headers, cookies) | ParameterLocation.Query -> let listValues = coerceQueryString name valueExpr - let query' = <@ List.append %query %listValues @> - (path, query', headers, cookies) + (path, listValues :: queryParamLists, headers, cookies) | ParameterLocation.Header -> let value = coerceString valueExpr let headers' = <@ (name, %value) :: (%headers) @> - (path, query, headers', cookies) + (path, queryParamLists, headers', cookies) | ParameterLocation.Cookie -> let value = coerceString valueExpr let cookies' = <@ (name, %value) :: (%cookies) @> - (path, query, headers, cookies') + (path, queryParamLists, headers, cookies') | x -> failwithf $"Unsupported parameter location '%A{x}'" else failwithf "This should not happen, payload expression is already parsed") - let headers' = - <@ - let cookieHeader = - %cookies - |> Seq.filter(snd >> isNull >> not) - |> Seq.map(fun (name, value) -> $"{name}={value}") - |> String.concat ";" - - if String.IsNullOrEmpty cookieHeader then - %headers - else - ("Cookie", cookieHeader) :: (%headers) - @> - - (path, queryParams, headers') + path, List.rev queryParamLists, headers, cookies + let queryParamLists = typedListExpr queryParamLists let httpRequestMessage = <@ - let msg = RuntimeHelpers.createHttpRequest httpMethod %path %queryParams - RuntimeHelpers.fillHeaders msg %headers + let msg = + RuntimeHelpers.createHttpRequestFromQueryLists httpMethod %path %queryParamLists + + RuntimeHelpers.fillHeadersAndCookies msg %headers %cookies msg @> @@ -441,7 +456,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let action = <@ (%this).CallAsync(%httpRequestMessageWithPayload, errorCodes, errorDescriptions, %ct) @> - let responseObj = + let responseObj() = let innerReturnType = defaultArg retTy null <@ @@ -455,7 +470,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, } @> - let responseStream = + let responseStream() = <@ let x = %action let ct = %ct @@ -467,7 +482,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, } @> - let responseString = + let responseString() = <@ let x = %action let ct = %ct @@ -479,7 +494,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, } @> - let responseUnit = + let responseUnit() = <@ let x = %action @@ -489,27 +504,36 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, } @> - // if we're an async method, then we can just return the above, coerced to the overallReturnType. - // if we're not async, then run that^ through Async.RunSynchronously before doing the coercion. + // Build only the response quotation needed for this operation's return shape. + // For typed JSON responses, emit direct generic cast calls so generated clients + // do not pay MethodInfo.Invoke costs on every API call. if not asAsync then match retTy with - | None -> responseUnit.Raw - | Some t when t = typeof -> <@ %responseStream @>.Raw + | None -> (responseUnit()).Raw + | Some t when t = typeof -> <@ %(responseStream()) @>.Raw | Some t -> match retMime with - | TextReturn _ -> <@ %responseString @>.Raw - | _ -> Expr.Coerce(<@ RuntimeHelpers.taskCast t %responseObj @>, overallReturnType) + | TextReturn _ -> <@ %(responseString()) @>.Raw + | _ -> + let castMethod = ProvidedTypeBuilder.MakeGenericMethod(taskCastMethod, [ t ]) + + Expr.Call(castMethod, [ responseObj() ]) + |> fun e -> Expr.Coerce(e, overallReturnType) else let awaitTask t = <@ Async.AwaitTask(%t) @> match retTy with - | None -> (awaitTask responseUnit).Raw - | Some t when t = typeof -> <@ %(awaitTask responseStream) @>.Raw + | None -> (awaitTask(responseUnit())).Raw + | Some t when t = typeof -> <@ %(awaitTask(responseStream())) @>.Raw | Some t -> match retMime with - | TextReturn _ -> <@ %(awaitTask responseString) @>.Raw - | _ -> Expr.Coerce(<@ RuntimeHelpers.asyncCast t %(awaitTask responseObj) @>, overallReturnType) + | TextReturn _ -> <@ %(awaitTask(responseString())) @>.Raw + | _ -> + let castMethod = ProvidedTypeBuilder.MakeGenericMethod(asyncCastMethod, [ t ]) + + Expr.Call(castMethod, [ awaitTask(responseObj()) ]) + |> fun e -> Expr.Coerce(e, overallReturnType) ) let xmlDoc = diff --git a/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs b/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs index 43b23c4a..e0a00314 100644 --- a/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs +++ b/src/SwaggerProvider.DesignTime/Provider.OpenApiClient.fs @@ -30,13 +30,14 @@ type public OpenApiClientTypeProvider(cfg: TypeProviderConfig) as this = // check we contain a copy of runtime files, and are not referencing the runtime DLL do assert (typeof.Assembly.GetName().Name = asm.GetName().Name) - let buildStringListExpr(items: string list) : Expr = + let stringListNilCase, stringListConsCase = let cases = FSharpType.GetUnionCases typeof - let nilCase = cases |> Array.find(fun c -> c.Name = "Empty") - let consCase = cases |> Array.find(fun c -> c.Name = "Cons") - let nil = Expr.NewUnionCase(nilCase, []) + cases |> Array.find(fun c -> c.Name = "Empty"), cases |> Array.find(fun c -> c.Name = "Cons") + + let buildStringListExpr(items: string list) : Expr = + let nil = Expr.NewUnionCase(stringListNilCase, []) - List.foldBack (fun (s: string) acc -> Expr.NewUnionCase(consCase, [ Expr.Value(s, typeof); acc ])) items nil + List.foldBack (fun (s: string) acc -> Expr.NewUnionCase(stringListConsCase, [ Expr.Value(s, typeof); acc ])) items nil let myParamType = let t = diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 0e84deeb..c6f427af 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -3,6 +3,7 @@ namespace Swagger.Internal open System open System.Net.Http open System.Net.Http.Headers +open System.Text open System.Text.Json.Serialization open System.Threading.Tasks @@ -577,6 +578,16 @@ module RuntimeHelpers = let method = resolveHttpMethod httpMethod new HttpRequestMessage(method, Uri(requestUrl, UriKind.Relative)) + let createHttpRequestFromQueryLists (httpMethod: string) (address: string) (queryParamLists: seq<#seq>) = + let queryParams = ResizeArray() + + for queryParamList in queryParamLists do + for name, value in queryParamList do + if not(isNull value) then + queryParams.Add(name, value) + + createHttpRequest httpMethod address queryParams + let fillHeaders (msg: HttpRequestMessage) (headers: (string * string) seq) = headers |> Seq.filter(snd >> isNull >> not) @@ -587,6 +598,25 @@ module RuntimeHelpers = if (name <> "Content-Type") then raise <| Exception(errMsg)) + let fillHeadersAndCookies (msg: HttpRequestMessage) (headers: (string * string) seq) (cookies: (string * string) seq) = + fillHeaders msg headers + + let cookieHeader = StringBuilder() + + for name, value in cookies do + if not(isNull value) then + if cookieHeader.Length > 0 then + cookieHeader.Append(';').Append(' ') |> ignore + + cookieHeader.Append(name).Append('=').Append(value) |> ignore + + if cookieHeader.Length > 0 then + let value = cookieHeader.ToString() + + if not <| msg.Headers.TryAddWithoutValidation("Cookie", value) then + raise + <| Exception($"Cannot add header 'Cookie'='{value}' to HttpRequestMessage") + /// Resolves a public static generic method definition with one type parameter and one /// value parameter by name from the given type. Raises a descriptive exception if the /// method cannot be uniquely identified, avoiding AmbiguousMatchException from a @@ -634,6 +664,7 @@ module RuntimeHelpers = content.ReadAsStreamAsync() #endif + let taskCast runtimeTy (task: Task) = let m = taskCastCache.GetOrAdd(runtimeTy, fun t -> taskCastMethod.MakeGenericMethod([| t |])) diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 325f3c48..75e5afa0 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -667,6 +667,103 @@ module FillHeadersTests = fillHeaders req [ ("Content-Type", "application/json") ] +module CreateHttpRequestFromQueryListsTests = + + [] + let ``createHttpRequestFromQueryLists flattens multiple query lists``() = + use req = + createHttpRequestFromQueryLists "GET" "v1/items" [ [ ("a", "1"); ("b", "2") ]; [ ("c", "3") ] ] + + req.RequestUri.OriginalString |> shouldEqual "v1/items?a=1&b=2&c=3" + + [] + let ``createHttpRequestFromQueryLists with all empty lists strips leading slash``() = + use req = + createHttpRequestFromQueryLists "GET" "/pets" ([]: (string * string) list list) + + req.RequestUri.OriginalString |> shouldEqual "pets" + + [] + let ``createHttpRequestFromQueryLists with empty inner lists strips leading slash``() = + use req = + createHttpRequestFromQueryLists "GET" "/pets" [ ([]: (string * string) list); [] ] + + req.RequestUri.OriginalString |> shouldEqual "pets" + + [] + let ``createHttpRequestFromQueryLists skips null-valued pairs``() = + use req = + createHttpRequestFromQueryLists "GET" "v1/items" [ [ ("a", "1"); ("b", null) ]; [ ("c", null); ("d", "4") ] ] + + req.RequestUri.OriginalString |> shouldEqual "v1/items?a=1&d=4" + + [] + let ``createHttpRequestFromQueryLists with only null values produces no query string``() = + use req = + createHttpRequestFromQueryLists "GET" "/pets" [ [ ("a", null); ("b", null) ] ] + + req.RequestUri.OriginalString |> shouldEqual "pets" + + [] + let ``createHttpRequestFromQueryLists preserves method``() = + use req = + createHttpRequestFromQueryLists "POST" "v1/items" ([]: (string * string) list list) + + req.Method |> shouldEqual HttpMethod.Post + + +module FillHeadersAndCookiesTests = + + [] + let ``fillHeadersAndCookies emits Cookie header with canonical '; ' separator``() = + use req = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + fillHeadersAndCookies req [] [ ("a", "1"); ("b", "2"); ("c", "3") ] + let cookie = req.Headers.GetValues("Cookie") |> Seq.exactlyOne + cookie |> shouldEqual "a=1; b=2; c=3" + + [] + let ``fillHeadersAndCookies single cookie has no separator``() = + use req = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + fillHeadersAndCookies req [] [ ("session", "abc") ] + let cookie = req.Headers.GetValues("Cookie") |> Seq.exactlyOne + cookie |> shouldEqual "session=abc" + + [] + let ``fillHeadersAndCookies skips null cookie values``() = + use req = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + fillHeadersAndCookies req [] [ ("a", "1"); ("b", null); ("c", "3") ] + let cookie = req.Headers.GetValues("Cookie") |> Seq.exactlyOne + cookie |> shouldEqual "a=1; c=3" + + [] + let ``fillHeadersAndCookies omits Cookie header when all values are null``() = + use req = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + fillHeadersAndCookies req [] [ ("a", null); ("b", null) ] + req.Headers.Contains("Cookie") |> shouldEqual false + + [] + let ``fillHeadersAndCookies omits Cookie header when cookie list is empty``() = + use req = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + fillHeadersAndCookies req [ ("Accept", "application/json") ] [] + req.Headers.Contains("Cookie") |> shouldEqual false + + [] + let ``fillHeadersAndCookies adds normal headers via fillHeaders``() = + use req = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + + fillHeadersAndCookies req [ ("Accept", "application/json"); ("X-Api-Key", "secret") ] [ ("session", "abc") ] + + req.Headers.Contains("Accept") |> shouldEqual true + req.Headers.Contains("X-Api-Key") |> shouldEqual true + req.Headers.Contains("Cookie") |> shouldEqual true + + [] + let ``fillHeadersAndCookies skips null-value headers like fillHeaders``() = + use req = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + fillHeadersAndCookies req [ ("X-Missing", null) ] [] + req.Headers.Contains("X-Missing") |> shouldEqual false + + module ToContentTests = [] diff --git a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs index f3eaae67..32a300d1 100644 --- a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs @@ -8,6 +8,9 @@ open System open System.Reflection open System.Threading open System.Threading.Tasks +open Microsoft.FSharp.Quotations +open Microsoft.FSharp.Quotations.ExprShape +open Microsoft.FSharp.Quotations.Patterns open Xunit open FsUnitTyped @@ -24,6 +27,37 @@ let private findMethod (types: ProviderImplementation.ProvidedTypes.ProvidedType |> List.collect(fun t -> t.GetMethods() |> Array.toList) |> List.tryFind(fun m -> m.Name = methodName) +let private getInvokeCode(method: MethodInfo) = + let providedMethod = method :?> ProviderImplementation.ProvidedTypes.ProvidedMethod + + let invokeCodeProp = + providedMethod.GetType().GetProperty("GetInvokeCode", BindingFlags.Instance ||| BindingFlags.NonPublic) + + if isNull invokeCodeProp then + failwith "GetInvokeCode property not found on ProvidedMethod" + + match invokeCodeProp.GetValue(providedMethod) :?> (Expr list -> Expr) option with + | Some invokeCode -> invokeCode + | None -> failwith $"Method '%s{method.Name}' has no invoke code" + +let private letBoundVars expr = + let rec loop expr = + match expr with + | Let(v, value, body) -> v :: (loop value @ loop body) + | ShapeVar _ -> [] + | ShapeLambda(_, body) -> loop body + | ShapeCombination(_, args) -> args |> List.collect loop + + loop expr + +let private containsDuplicateVarObject vars = + vars + |> List.mapi(fun i v -> + vars + |> List.skip(i + 1) + |> List.exists(fun other -> obj.ReferenceEquals(v, other))) + |> List.exists id + // ── Simple GET with no parameters ───────────────────────────────────────────── let private simpleGetSchema = @@ -706,6 +740,73 @@ let ``multiple path params appear before CancellationToken``() = parameters.Length |> shouldEqual 3 // userId, postId, CancellationToken +[] +let ``invokeCode for multiple path params does not reuse the same quotation Var binding``() = + let types = compileTaskSchema multiplePathParamsSchema + let method = (findMethod types "GetUserPost").Value + let invokeCode = getInvokeCode method + + let thisExpr = Expr.Var(Var("this", method.DeclaringType)) + let userIdExpr = Expr.Var(Var("userId", typeof)) + let postIdExpr = Expr.Var(Var("postId", typeof)) + let ctExpr = Expr.Var(Var("cancellationToken", typeof)) + + let body = invokeCode [ thisExpr; userIdExpr; postIdExpr; ctExpr ] + + body + |> letBoundVars + |> containsDuplicateVarObject + |> shouldEqual false + +let private multipleQueryParamsSchema = + """openapi: "3.0.0" +info: + title: MultipleQueryParamsTest + version: "1.0.0" +paths: + /search: + get: + operationId: searchItems + parameters: + - name: q + in: query + required: true + schema: + type: string + - name: page + in: query + required: true + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: string +components: + schemas: {} +""" + +[] +let ``invokeCode for multiple query params does not reuse the same quotation Var binding``() = + let types = compileTaskSchema multipleQueryParamsSchema + let method = (findMethod types "SearchItems").Value + let invokeCode = getInvokeCode method + + let thisExpr = Expr.Var(Var("this", method.DeclaringType)) + let qExpr = Expr.Var(Var("q", typeof)) + let pageExpr = Expr.Var(Var("page", typeof)) + let ctExpr = Expr.Var(Var("cancellationToken", typeof)) + + let body = invokeCode [ thisExpr; qExpr; pageExpr; ctExpr ] + + body + |> letBoundVars + |> containsDuplicateVarObject + |> shouldEqual false + // ── PATCH operation ──────────────────────────────────────────────────────────── let private patchSchema =