From 2ab3462f832fc5baaa445bfb68f58e67bdcf1b5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:12:37 +0000 Subject: [PATCH 01/11] Fix NullReferenceException for parameters defined via content instead of schema (#501) Per the OpenAPI spec, a parameter's type may be described either by `schema` or by `content` (a map with a single media type entry), but not both. The operation compiler only ever read `IOpenApiParameter.Schema`, which is null for content-typed parameters, so `defCompiler.CompileTy` received a null schema and threw a NullReferenceException deep inside DefinitionCompiler. Added `resolveParamSchema` which falls back to the schema of the sole `content` entry when `Schema` is null, and used it both when compiling the parameter's provided type and when building its XML doc enum description. Added a regression test using the exact schema from the issue report. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OperationCompiler.fs | 20 +++++-- .../Schema.OperationCompilationTests.fs | 60 +++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index fb2f1fa7..d8beef31 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -109,6 +109,17 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let unambiguousName(par: IOpenApiParameter) = $"%s{par.Name}In%A{par.In}" + // Per the OpenAPI spec, a parameter defines its type via either `schema` + // or `content` (a map with a single media type entry), but not both. + // Fall back to the schema of the sole `content` entry when `schema` is absent. + let resolveParamSchema(param: IOpenApiParameter) = + if not(isNull param.Schema) then + param.Schema + elif not(isNull param.Content) && param.Content.Count > 0 then + (Seq.head param.Content.Values).Schema + else + null + let openApiParameters = [ if not(isNull pathItem.Parameters) then @@ -204,7 +215,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let names, paramName = uniqueParamName names current let paramType = - defCompiler.CompileTy providedMethodName paramName current.Schema current.Required + defCompiler.CompileTy providedMethodName paramName (resolveParamSchema current) current.Required let providedParam = if current.Required then @@ -545,10 +556,9 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let xmlDoc = let buildParamDesc(p: IOpenApiParameter) = let enumDoc = - if not(isNull p.Schema) then - XmlDoc.buildEnumDoc p.Schema.Enum - else - None + match resolveParamSchema p with + | null -> None + | schema -> XmlDoc.buildEnumDoc schema.Enum XmlDoc.combineDescAndEnum p.Description enumDoc diff --git a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs index 0bd9095a..0678864d 100644 --- a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs @@ -1700,3 +1700,63 @@ let ``200 response schema is used not 201 when both are present``() = let returnArg = method.ReturnType.GetGenericArguments()[0] returnArg |> shouldNotEqual typeof returnArg |> shouldEqual typeof + +// ── Parameter defined via `content` instead of `schema` (regression, issue #501) ── + +/// Per the OpenAPI spec, a parameter's type may be described either by `schema` +/// or by `content` (a map with a single media type entry) but not both. Previously +/// the compiler only ever read `parameter.Schema`, which is null for content-typed +/// parameters, causing a NullReferenceException deep in the definition compiler. +let private contentTypedHeaderParamSchema = + """openapi: "3.1.1" +info: + title: Testing | v1 + version: "0.2.0.0" +paths: + /api/v1/catalogues/scan: + post: + tags: + - Catalogue + summary: Testing + description: Testing + operationId: Scan + parameters: + - name: properties + in: header + description: header description + content: + application/json: + schema: + type: object + properties: + stage-location: + type: string + description: location + file-size: + minimum: 0 + type: integer + format: int64 + responses: + "200": + description: OK + content: + text/plain: + schema: + type: string +components: + schemas: {} +""" + +[] +let ``content-typed parameter does not throw and is compiled using its content schema``() = + let types = compileTaskSchema contentTypedHeaderParamSchema + let method = (findMethod types "Scan").Value + let parameters = method.GetParameters() + // properties (object, required by default since Required is unset -> false) + cancellationToken + let paramNames = parameters |> Array.map(fun p -> p.Name) + paramNames |> shouldContain "properties" + + let propertiesParam = parameters |> Array.find(fun p -> p.Name = "properties") + // The content schema is an inline object with properties, so it must compile to a + // generated provided type (not `obj`, which would indicate the schema was lost). + propertiesParam.ParameterType |> shouldNotEqual typeof From da7942b37eb453c39eaa305a10db3a595a3766fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:43:48 +0000 Subject: [PATCH 02/11] Fix content-typed parameter serialization Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../OperationCompiler.fs | 97 +++++++++++-- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 32 +++++ .../RuntimeHelpersTests.fs | 30 ++++ .../Schema.OperationCompilationTests.fs | 134 ++++++++++++++++++ 4 files changed, 280 insertions(+), 13 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index d8beef31..fabf5d45 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -71,6 +71,16 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, | Call(None, m, _) -> m | _ -> failwith "Cannot extract toQueryParams MethodInfo" + let toJsonParamMethod = + match <@@ RuntimeHelpers.toJsonParam null Unchecked.defaultof @@> with + | Call(None, m, _) -> m + | _ -> failwith "Cannot extract toJsonParam MethodInfo" + + let toJsonQueryParamMethod = + match <@@ RuntimeHelpers.toJsonQueryParam "" null Unchecked.defaultof @@> with + | Call(None, m, _) -> m + | _ -> failwith "Cannot extract toJsonQueryParam MethodInfo" + let resolveCastMethod(ownerType: Type) = ownerType.GetMethods(BindingFlags.Public ||| BindingFlags.Static) |> Array.tryFind(fun m -> @@ -109,16 +119,69 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let unambiguousName(par: IOpenApiParameter) = $"%s{par.Name}In%A{par.In}" + let operationId = + if String.IsNullOrWhiteSpace(operation.OperationId) then + $"%s{path}/%A{opTy}" + else + operation.OperationId + + let hasSchemaAndContent(param: IOpenApiParameter) = + not(isNull param.Schema) + && not(isNull param.Content) + && param.Content.Count > 0 + + let validateParamSchemaAndContent(param: IOpenApiParameter) = + if hasSchemaAndContent param then + failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' cannot define both schema and content" + + let tryGetSingleParamContent(param: IOpenApiParameter) = + validateParamSchemaAndContent param + + if isNull param.Content || param.Content.Count = 0 then + None + elif param.Content.Count = 1 then + let kv = param.Content |> Seq.head + Some(kv.Key, kv.Value) + else + let mediaTypes = param.Content.Keys |> String.concat ";" + failwithf + $"Operation '%s{operationId}' parameter '%s{param.Name}' defines content entries [%s{mediaTypes}], but parameters defined via content must contain exactly one media type entry" + + let resolveParamContentSchema(param: IOpenApiParameter) = + match tryGetSingleParamContent param with + | Some(mediaType, mediaTy) when isNull mediaTy.Schema -> + failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' content media type '%s{mediaType}' must define a schema" + | Some(_, mediaTy) -> Some mediaTy.Schema + | None -> None + + let isJsonCompatibleMediaType(mediaType: string) = + if String.IsNullOrWhiteSpace mediaType then + false + else + let canonicalType = mediaType.Split(';').[0].Trim() + + canonicalType.Equals(MediaTypes.ApplicationJson, StringComparison.OrdinalIgnoreCase) + || canonicalType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) + // Per the OpenAPI spec, a parameter defines its type via either `schema` // or `content` (a map with a single media type entry), but not both. - // Fall back to the schema of the sole `content` entry when `schema` is absent. + // Fall back to the schema of the sole `content` entry when `schema` is absent, + // and preserve the media type so request generation can serialize correctly. + let resolveParamContentMediaType(param: IOpenApiParameter) = + match tryGetSingleParamContent param with + | Some(mediaType, _) when isJsonCompatibleMediaType mediaType -> Some MediaTypes.ApplicationJson + | Some(mediaType, _) -> + failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' uses unsupported content media type '%s{mediaType}'" + | None -> None + let resolveParamSchema(param: IOpenApiParameter) = + validateParamSchemaAndContent param + if not(isNull param.Schema) then param.Schema - elif not(isNull param.Content) && param.Content.Count > 0 then - (Seq.head param.Content.Values).Schema else - null + resolveParamContentSchema param + |> Option.toObj let openApiParameters = [ @@ -378,15 +441,23 @@ 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 coerceString exp = + let coerceString (param: IOpenApiParameter) exp = let obj = Expr.Coerce(exp, typeof) - Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast - let rec coerceQueryString name expr = + match resolveParamContentMediaType param with + | Some _ -> Expr.Call(toJsonParamMethod, [ obj; this ]) |> Expr.Cast + | None -> Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast + + let rec coerceQueryString (param: IOpenApiParameter) name expr = let obj = Expr.Coerce(expr, typeof) - Expr.Call(toQueryParamsMethod, [ Expr.Value name; obj; this ]) - |> Expr.Cast<(string * string) list> + match resolveParamContentMediaType param with + | Some _ -> + Expr.Call(toJsonQueryParamMethod, [ Expr.Value name; obj; this ]) + |> Expr.Cast<(string * string) list> + | None -> + Expr.Call(toQueryParamsMethod, [ Expr.Value name; obj; this ]) + |> Expr.Cast<(string * string) list> // Partitions arguments based on their locations let path, queryParamLists, headers, cookies = @@ -398,19 +469,19 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, match param.In.Value with | ParameterLocation.Path -> - let value = coerceString valueExpr + let value = coerceString param valueExpr let pattern = $"{{%s{name}}}" let path' = <@ (%path).Replace(pattern, %value) @> (path', queryParamLists, headers, cookies) | ParameterLocation.Query -> - let listValues = coerceQueryString name valueExpr + let listValues = coerceQueryString param name valueExpr (path, listValues :: queryParamLists, headers, cookies) | ParameterLocation.Header -> - let value = coerceString valueExpr + let value = coerceString param valueExpr let headers' = <@ (name, %value) :: (%headers) @> (path, queryParamLists, headers', cookies) | ParameterLocation.Cookie -> - let value = coerceString valueExpr + let value = coerceString param valueExpr let cookies' = <@ (name, %value) :: (%cookies) @> (path, queryParamLists, headers, cookies') | x -> failwithf $"Unsupported parameter location '%A{x}'" diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 6b0abaee..bb967f7a 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -219,6 +219,26 @@ module RuntimeHelpers = let private enumSerializerFactory = System.Func string>(buildEnumSerializer) + let rec private unwrapOptionalValue(obj: obj) = + if isNull obj then + null + else + let ty = obj.GetType() + + if + ty.IsGenericType + && ty.GetGenericTypeDefinition() = typedefof> + then + let tagReader = optionTagReaderCache.GetOrAdd(ty, optionTagReaderFactory) + + if tagReader obj = 1 then // 1 = Some + let valueProp = optionValueCache.GetOrAdd(ty, optionValueFactory) + unwrapOptionalValue(valueProp.GetValue(obj)) + else + null + else + obj + let rec toParam(obj: obj) = match obj with | :? DateTime as dt -> dt.ToString("O") @@ -273,6 +293,18 @@ module RuntimeHelpers = else obj.ToString() + let toJsonParam(obj: obj) (client: Swagger.ProvidedApiClientBase) = + let value = unwrapOptionalValue obj + + if isNull value then + null + else + client.Serialize value + + let toJsonQueryParam (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) = + let param = toJsonParam obj client + if isNull param then [] else [ name, param ] + let toQueryParams (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) = if isNull obj then [] diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index ff8f398a..d3b51a04 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -248,6 +248,36 @@ module EnumToParamTests = result |> shouldEqual "inactive" +module ToJsonParamTests = + + let private stubClient = + { new Swagger.ProvidedApiClientBase(null, JsonSerializerOptions()) with + override _.Serialize(v) = + JsonSerializer.Serialize v + + override _.Deserialize(s, t) = + JsonSerializer.Deserialize(s, t) + } + + [] + let ``toJsonParam serializes object values with the client serializer``() = + let result = toJsonParam (box {| stageLocation = "stage-a"; fileSize = 42L |}) stubClient + result |> shouldContainText "\"stageLocation\":\"stage-a\"" + result |> shouldContainText "\"fileSize\":42" + + [] + let ``toJsonParam returns null for Option None``() = + let result = toJsonParam (box(None: string option)) stubClient + result |> shouldEqual null + + [] + let ``toJsonQueryParam keeps content-typed values as a single serialized pair``() = + let result = toJsonQueryParam "properties" (box {| stageLocation = "stage-a"; fileSize = 42L |}) stubClient + result |> shouldHaveLength 1 + fst result[0] |> shouldEqual "properties" + snd result[0] |> shouldContainText "\"stageLocation\":\"stage-a\"" + snd result[0] |> shouldContainText "\"fileSize\":42" + module ToQueryParamsTests = let private stubClient = diff --git a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs index 0678864d..f3c2dec7 100644 --- a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs @@ -58,6 +58,16 @@ let private containsDuplicateVarObject vars = |> List.exists(fun other -> obj.ReferenceEquals(v, other))) |> List.exists id +let private containsMethodCall (methodName: string) expr = + let rec loop expr = + match expr with + | Call(_, m, args) -> m.Name = methodName || (args |> List.exists loop) + | ShapeVar _ -> false + | ShapeLambda(_, body) -> loop body + | ShapeCombination(_, args) -> args |> List.exists loop + + loop expr + // ── Simple GET with no parameters ───────────────────────────────────────────── let private simpleGetSchema = @@ -1747,6 +1757,96 @@ components: schemas: {} """ +let private contentTypedQueryParamSchema = + """openapi: "3.1.1" +info: + title: QueryContentTypedParameterTest + version: "1.0.0" +paths: + /api/v1/catalogues/search: + get: + operationId: Search + parameters: + - name: filters + in: query + content: + application/problem+json: + schema: + type: object + properties: + stage-location: + type: string + responses: + "200": + description: OK + content: + text/plain: + schema: + type: string +components: + schemas: {} +""" + +[] +let ``content-typed parameter rejects invalid definitions that specify both schema and content``() = + let invalidSchema = + """openapi: "3.1.1" +info: + title: InvalidParameterTest + version: "1.0.0" +paths: + /scan: + post: + operationId: Scan + parameters: + - name: properties + in: header + schema: + type: string + content: + application/json: + schema: + type: object + properties: + stage-location: + type: string + responses: + "200": + description: OK +components: + schemas: {} +""" + + let ex = Assert.ThrowsAny(fun () -> compileTaskSchema invalidSchema |> ignore) + ex.Message |> shouldContainText "cannot define both schema and content" + +[] +let ``content-typed parameter rejects content entries without a schema``() = + let invalidSchema = + """openapi: "3.1.1" +info: + title: InvalidContentSchemaTest + version: "1.0.0" +paths: + /scan: + post: + operationId: Scan + parameters: + - name: properties + in: header + content: + application/json: + examples: {} + responses: + "200": + description: OK +components: + schemas: {} +""" + + let ex = Assert.ThrowsAny(fun () -> compileTaskSchema invalidSchema |> ignore) + ex.Message |> shouldContainText "must define a schema" + [] let ``content-typed parameter does not throw and is compiled using its content schema``() = let types = compileTaskSchema contentTypedHeaderParamSchema @@ -1760,3 +1860,37 @@ let ``content-typed parameter does not throw and is compiled using its content s // The content schema is an inline object with properties, so it must compile to a // generated provided type (not `obj`, which would indicate the schema was lost). propertiesParam.ParameterType |> shouldNotEqual typeof + +[] +let ``content-typed json header uses json parameter serialization in request generation``() = + let types = compileTaskSchema contentTypedHeaderParamSchema + let method = (findMethod types "Scan").Value + let invokeCode = getInvokeCode method + let parameters = method.GetParameters() + let propertiesParam = parameters |> Array.find(fun p -> p.Name = "properties") + + let thisExpr = Expr.Var(Var("this", method.DeclaringType)) + let propertiesExpr = Expr.Var(Var("properties", propertiesParam.ParameterType)) + let ctExpr = Expr.Var(Var("cancellationToken", typeof)) + let body = invokeCode [ thisExpr; propertiesExpr; ctExpr ] + + body + |> containsMethodCall "toJsonParam" + |> shouldEqual true + +[] +let ``content-typed json query parameter uses json query serialization in request generation``() = + let types = compileTaskSchema contentTypedQueryParamSchema + let method = (findMethod types "Search").Value + let invokeCode = getInvokeCode method + let parameters = method.GetParameters() + let filtersParam = parameters |> Array.find(fun p -> p.Name = "filters") + + let thisExpr = Expr.Var(Var("this", method.DeclaringType)) + let filtersExpr = Expr.Var(Var("filters", filtersParam.ParameterType)) + let ctExpr = Expr.Var(Var("cancellationToken", typeof)) + let body = invokeCode [ thisExpr; filtersExpr; ctExpr ] + + body + |> containsMethodCall "toJsonQueryParam" + |> shouldEqual true From fe23d9d15ac11e0ba04ab4f42df109f22c401a5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:04:53 +0000 Subject: [PATCH 03/11] Handle content-typed params without schema in OpenAPI 3.1 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../OperationCompiler.fs | 7 +++---- .../Schema.OperationCompilationTests.fs | 21 ++++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index fabf5d45..a752ab9d 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -144,13 +144,13 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, Some(kv.Key, kv.Value) else let mediaTypes = param.Content.Keys |> String.concat ";" + failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' defines content entries [%s{mediaTypes}], but parameters defined via content must contain exactly one media type entry" let resolveParamContentSchema(param: IOpenApiParameter) = match tryGetSingleParamContent param with - | Some(mediaType, mediaTy) when isNull mediaTy.Schema -> - failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' content media type '%s{mediaType}' must define a schema" + | Some(_, mediaTy) when isNull mediaTy.Schema -> Some(OpenApiSchema() :> IOpenApiSchema) | Some(_, mediaTy) -> Some mediaTy.Schema | None -> None @@ -180,8 +180,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, if not(isNull param.Schema) then param.Schema else - resolveParamContentSchema param - |> Option.toObj + resolveParamContentSchema param |> Option.toObj let openApiParameters = [ diff --git a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs index f3c2dec7..a838d440 100644 --- a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs @@ -1818,10 +1818,12 @@ components: """ let ex = Assert.ThrowsAny(fun () -> compileTaskSchema invalidSchema |> ignore) - ex.Message |> shouldContainText "cannot define both schema and content" + + ex.Message + |> shouldContainText "cannot define both schema and content" [] -let ``content-typed parameter rejects content entries without a schema``() = +let ``content-typed parameter without schema compiles and falls back to obj``() = let invalidSchema = """openapi: "3.1.1" info: @@ -1844,8 +1846,11 @@ components: schemas: {} """ - let ex = Assert.ThrowsAny(fun () -> compileTaskSchema invalidSchema |> ignore) - ex.Message |> shouldContainText "must define a schema" + let types = compileTaskSchema invalidSchema + let method = (findMethod types "Scan").Value + let parameters = method.GetParameters() + let propertiesParam = parameters |> Array.find(fun p -> p.Name = "properties") + propertiesParam.ParameterType |> shouldEqual typeof [] let ``content-typed parameter does not throw and is compiled using its content schema``() = @@ -1874,9 +1879,7 @@ let ``content-typed json header uses json parameter serialization in request gen let ctExpr = Expr.Var(Var("cancellationToken", typeof)) let body = invokeCode [ thisExpr; propertiesExpr; ctExpr ] - body - |> containsMethodCall "toJsonParam" - |> shouldEqual true + body |> containsMethodCall "toJsonParam" |> shouldEqual true [] let ``content-typed json query parameter uses json query serialization in request generation``() = @@ -1891,6 +1894,4 @@ let ``content-typed json query parameter uses json query serialization in reques let ctExpr = Expr.Var(Var("cancellationToken", typeof)) let body = invokeCode [ thisExpr; filtersExpr; ctExpr ] - body - |> containsMethodCall "toJsonQueryParam" - |> shouldEqual true + body |> containsMethodCall "toJsonQueryParam" |> shouldEqual true From 0cb3b879c6e62887ddc892b28c9c72f43ee1d133 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:06:54 +0000 Subject: [PATCH 04/11] Limit schema fallback to JSON-compatible content parameters Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../OperationCompiler.fs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index a752ab9d..6f33f8b6 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -150,7 +150,21 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let resolveParamContentSchema(param: IOpenApiParameter) = match tryGetSingleParamContent param with - | Some(_, mediaTy) when isNull mediaTy.Schema -> Some(OpenApiSchema() :> IOpenApiSchema) + | Some(mediaType, mediaTy) when isNull mediaTy.Schema -> + let canonicalType = + if String.IsNullOrWhiteSpace mediaType then + null + else + mediaType.Split(';').[0].Trim() + + if + not(isNull canonicalType) + && (canonicalType.Equals(MediaTypes.ApplicationJson, StringComparison.OrdinalIgnoreCase) + || canonicalType.EndsWith("+json", StringComparison.OrdinalIgnoreCase)) + then + Some(OpenApiSchema() :> IOpenApiSchema) + else + failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' uses unsupported content media type '%s{mediaType}'" | Some(_, mediaTy) -> Some mediaTy.Schema | None -> None From a56adf0af398405fabbdbfba675a03bb1ee4af0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:08:23 +0000 Subject: [PATCH 05/11] Reject non-JSON content parameters during schema resolution Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- .../OperationCompiler.fs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index 6f33f8b6..ac4895f5 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -149,22 +149,23 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, $"Operation '%s{operationId}' parameter '%s{param.Name}' defines content entries [%s{mediaTypes}], but parameters defined via content must contain exactly one media type entry" let resolveParamContentSchema(param: IOpenApiParameter) = + let isJsonMediaType mediaType = + if String.IsNullOrWhiteSpace mediaType then + false + else + let canonicalType = mediaType.Split(';').[0].Trim() + + canonicalType.Equals(MediaTypes.ApplicationJson, StringComparison.OrdinalIgnoreCase) + || canonicalType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) + match tryGetSingleParamContent param with | Some(mediaType, mediaTy) when isNull mediaTy.Schema -> - let canonicalType = - if String.IsNullOrWhiteSpace mediaType then - null - else - mediaType.Split(';').[0].Trim() - - if - not(isNull canonicalType) - && (canonicalType.Equals(MediaTypes.ApplicationJson, StringComparison.OrdinalIgnoreCase) - || canonicalType.EndsWith("+json", StringComparison.OrdinalIgnoreCase)) - then + if isJsonMediaType mediaType then Some(OpenApiSchema() :> IOpenApiSchema) else failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' uses unsupported content media type '%s{mediaType}'" + | Some(mediaType, _) when not(isJsonMediaType mediaType) -> + failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' uses unsupported content media type '%s{mediaType}'" | Some(_, mediaTy) -> Some mediaTy.Schema | None -> None From aab6978d49cdb3c434297d26c13a9e1eb702834d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:34:53 +0000 Subject: [PATCH 06/11] Format RuntimeHelpers files to fix CheckFormat CI failure Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> --- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 7 ++----- .../RuntimeHelpersTests.fs | 21 +++++++++++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index bb967f7a..1694b3b6 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -293,13 +293,10 @@ module RuntimeHelpers = else obj.ToString() - let toJsonParam(obj: obj) (client: Swagger.ProvidedApiClientBase) = + let toJsonParam (obj: obj) (client: Swagger.ProvidedApiClientBase) = let value = unwrapOptionalValue obj - if isNull value then - null - else - client.Serialize value + if isNull value then null else client.Serialize value let toJsonQueryParam (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) = let param = toJsonParam obj client diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index d3b51a04..00552920 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -261,7 +261,15 @@ module ToJsonParamTests = [] let ``toJsonParam serializes object values with the client serializer``() = - let result = toJsonParam (box {| stageLocation = "stage-a"; fileSize = 42L |}) stubClient + let result = + toJsonParam + (box + {| + stageLocation = "stage-a" + fileSize = 42L + |}) + stubClient + result |> shouldContainText "\"stageLocation\":\"stage-a\"" result |> shouldContainText "\"fileSize\":42" @@ -272,7 +280,16 @@ module ToJsonParamTests = [] let ``toJsonQueryParam keeps content-typed values as a single serialized pair``() = - let result = toJsonQueryParam "properties" (box {| stageLocation = "stage-a"; fileSize = 42L |}) stubClient + let result = + toJsonQueryParam + "properties" + (box + {| + stageLocation = "stage-a" + fileSize = 42L + |}) + stubClient + result |> shouldHaveLength 1 fst result[0] |> shouldEqual "properties" snd result[0] |> shouldContainText "\"stageLocation\":\"stage-a\"" From 2135de76d2971aadd688397e58b7ceef11c7059d Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Tue, 29 Sep 2026 19:52:13 +0200 Subject: [PATCH 07/11] Potential fix for pull request finding 'Reject zero-entry content maps before CompileTy' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/OperationCompiler.fs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index ac4895f5..48c237b0 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -138,7 +138,11 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, validateParamSchemaAndContent param if isNull param.Content || param.Content.Count = 0 then - None + if isNull param.Schema then + failwithf + $"Operation '%s{operationId}' parameter '%s{param.Name}' must define either schema or exactly one content media type entry" + else + None elif param.Content.Count = 1 then let kv = param.Content |> Seq.head Some(kv.Key, kv.Value) From cd57b2aa5090d5d69b0df19d8cc1056708595d30 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sat, 3 Oct 2026 21:47:48 +0200 Subject: [PATCH 08/11] Simplify content-typed parameter handling (#501) - Single resolveParam helper replaces validation/resolution helpers - Prefer schema when both schema and content are defined - Non-JSON content uses its schema with plain serialization instead of failing - Percent-encode JSON values in path and cookie parameters --- .../OperationCompiler.fs | 127 ++++---------- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 6 + .../Schema.OperationCompilationTests.fs | 163 +++++++++++------- 3 files changed, 142 insertions(+), 154 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index 48c237b0..1b9b7e0a 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -76,6 +76,11 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, | Call(None, m, _) -> m | _ -> failwith "Cannot extract toJsonParam MethodInfo" + let toEscapedJsonParamMethod = + match <@@ RuntimeHelpers.toEscapedJsonParam null Unchecked.defaultof @@> with + | Call(None, m, _) -> m + | _ -> failwith "Cannot extract toEscapedJsonParam MethodInfo" + let toJsonQueryParamMethod = match <@@ RuntimeHelpers.toJsonQueryParam "" null Unchecked.defaultof @@> with | Call(None, m, _) -> m @@ -119,87 +124,27 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let unambiguousName(par: IOpenApiParameter) = $"%s{par.Name}In%A{par.In}" - let operationId = - if String.IsNullOrWhiteSpace(operation.OperationId) then - $"%s{path}/%A{opTy}" - else - operation.OperationId - - let hasSchemaAndContent(param: IOpenApiParameter) = - not(isNull param.Schema) - && not(isNull param.Content) - && param.Content.Count > 0 + let isJsonMediaType(mediaType: string) = + let t = mediaType.Split(';').[0].Trim() - let validateParamSchemaAndContent(param: IOpenApiParameter) = - if hasSchemaAndContent param then - failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' cannot define both schema and content" + t.Equals(MediaTypes.ApplicationJson, StringComparison.OrdinalIgnoreCase) + || t.EndsWith("+json", StringComparison.OrdinalIgnoreCase) - let tryGetSingleParamContent(param: IOpenApiParameter) = - validateParamSchemaAndContent param - - if isNull param.Content || param.Content.Count = 0 then - if isNull param.Schema then - failwithf - $"Operation '%s{operationId}' parameter '%s{param.Name}' must define either schema or exactly one content media type entry" - else - None - elif param.Content.Count = 1 then - let kv = param.Content |> Seq.head - Some(kv.Key, kv.Value) + /// OpenAPI 3.x: a parameter's type comes from `schema` or from its single `content` entry. + /// Returns (schema, serializeAsJson). `schema` wins if both are present (invalid, but seen in the wild). + let resolveParam(p: IOpenApiParameter) = + if not(isNull p.Schema) || isNull p.Content || p.Content.Count = 0 then + p.Schema, false else - let mediaTypes = param.Content.Keys |> String.concat ";" - - failwithf - $"Operation '%s{operationId}' parameter '%s{param.Name}' defines content entries [%s{mediaTypes}], but parameters defined via content must contain exactly one media type entry" + let kv = Seq.head p.Content // spec requires exactly one entry - let resolveParamContentSchema(param: IOpenApiParameter) = - let isJsonMediaType mediaType = - if String.IsNullOrWhiteSpace mediaType then - false - else - let canonicalType = mediaType.Split(';').[0].Trim() + let schema = + if isNull kv.Value.Schema then + OpenApiSchema() :> IOpenApiSchema + else + kv.Value.Schema - canonicalType.Equals(MediaTypes.ApplicationJson, StringComparison.OrdinalIgnoreCase) - || canonicalType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) - - match tryGetSingleParamContent param with - | Some(mediaType, mediaTy) when isNull mediaTy.Schema -> - if isJsonMediaType mediaType then - Some(OpenApiSchema() :> IOpenApiSchema) - else - failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' uses unsupported content media type '%s{mediaType}'" - | Some(mediaType, _) when not(isJsonMediaType mediaType) -> - failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' uses unsupported content media type '%s{mediaType}'" - | Some(_, mediaTy) -> Some mediaTy.Schema - | None -> None - - let isJsonCompatibleMediaType(mediaType: string) = - if String.IsNullOrWhiteSpace mediaType then - false - else - let canonicalType = mediaType.Split(';').[0].Trim() - - canonicalType.Equals(MediaTypes.ApplicationJson, StringComparison.OrdinalIgnoreCase) - || canonicalType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) - - // Per the OpenAPI spec, a parameter defines its type via either `schema` - // or `content` (a map with a single media type entry), but not both. - // Fall back to the schema of the sole `content` entry when `schema` is absent, - // and preserve the media type so request generation can serialize correctly. - let resolveParamContentMediaType(param: IOpenApiParameter) = - match tryGetSingleParamContent param with - | Some(mediaType, _) when isJsonCompatibleMediaType mediaType -> Some MediaTypes.ApplicationJson - | Some(mediaType, _) -> - failwithf $"Operation '%s{operationId}' parameter '%s{param.Name}' uses unsupported content media type '%s{mediaType}'" - | None -> None - - let resolveParamSchema(param: IOpenApiParameter) = - validateParamSchemaAndContent param - - if not(isNull param.Schema) then - param.Schema - else - resolveParamContentSchema param |> Option.toObj + schema, isJsonMediaType kv.Key let openApiParameters = [ @@ -296,7 +241,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let names, paramName = uniqueParamName names current let paramType = - defCompiler.CompileTy providedMethodName paramName (resolveParamSchema current) current.Required + defCompiler.CompileTy providedMethodName paramName (fst(resolveParam current)) current.Required let providedParam = if current.Required then @@ -459,21 +404,25 @@ 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 coerceString (param: IOpenApiParameter) exp = + // JSON values in path segments and cookies are percent-encoded: raw JSON + // contains characters ('/', '?', '"', ';', ',') that break those locations. + let coerceString escapeJson (param: IOpenApiParameter) exp = let obj = Expr.Coerce(exp, typeof) - match resolveParamContentMediaType param with - | Some _ -> Expr.Call(toJsonParamMethod, [ obj; this ]) |> Expr.Cast - | None -> Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast + match resolveParam param with + | _, true when escapeJson -> + Expr.Call(toEscapedJsonParamMethod, [ obj; this ]) + |> Expr.Cast + | _, true -> Expr.Call(toJsonParamMethod, [ obj; this ]) |> Expr.Cast + | _, false -> Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast - let rec coerceQueryString (param: IOpenApiParameter) name expr = + let coerceQueryString (param: IOpenApiParameter) name expr = let obj = Expr.Coerce(expr, typeof) - match resolveParamContentMediaType param with - | Some _ -> + if snd(resolveParam param) then Expr.Call(toJsonQueryParamMethod, [ Expr.Value name; obj; this ]) |> Expr.Cast<(string * string) list> - | None -> + else Expr.Call(toQueryParamsMethod, [ Expr.Value name; obj; this ]) |> Expr.Cast<(string * string) list> @@ -487,7 +436,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, match param.In.Value with | ParameterLocation.Path -> - let value = coerceString param valueExpr + let value = coerceString true param valueExpr let pattern = $"{{%s{name}}}" let path' = <@ (%path).Replace(pattern, %value) @> (path', queryParamLists, headers, cookies) @@ -495,11 +444,11 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let listValues = coerceQueryString param name valueExpr (path, listValues :: queryParamLists, headers, cookies) | ParameterLocation.Header -> - let value = coerceString param valueExpr + let value = coerceString false param valueExpr let headers' = <@ (name, %value) :: (%headers) @> (path, queryParamLists, headers', cookies) | ParameterLocation.Cookie -> - let value = coerceString param valueExpr + let value = coerceString true param valueExpr let cookies' = <@ (name, %value) :: (%cookies) @> (path, queryParamLists, headers, cookies') | x -> failwithf $"Unsupported parameter location '%A{x}'" @@ -645,7 +594,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let xmlDoc = let buildParamDesc(p: IOpenApiParameter) = let enumDoc = - match resolveParamSchema p with + match fst(resolveParam p) with | null -> None | schema -> XmlDoc.buildEnumDoc schema.Enum diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 1694b3b6..3a0864d5 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -298,6 +298,12 @@ module RuntimeHelpers = if isNull value then null else client.Serialize value + /// JSON-serialized and percent-encoded, for path segments and cookie values. + let toEscapedJsonParam (obj: obj) (client: Swagger.ProvidedApiClientBase) = + match toJsonParam obj client with + | null -> null + | s -> Uri.EscapeDataString s + let toJsonQueryParam (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) = let param = toJsonParam obj client if isNull param then [] else [ name, param ] diff --git a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs index a838d440..68ac7e60 100644 --- a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs @@ -1787,19 +1787,44 @@ components: schemas: {} """ -[] -let ``content-typed parameter rejects invalid definitions that specify both schema and content``() = - let invalidSchema = - """openapi: "3.1.1" +/// Builds the generated request body expression for an operation, binding every method parameter to a Var. +let private buildInvokeBody types opName = + let method = (findMethod types opName).Value + + let args = + Expr.Var(Var("this", method.DeclaringType)) + :: [ for p in method.GetParameters() -> Expr.Var(Var(p.Name, p.ParameterType)) ] + + getInvokeCode method args + +let private singleParamSchema(paramYaml: string) = + $"""openapi: "3.1.1" info: - title: InvalidParameterTest + title: ParameterTest version: "1.0.0" paths: - /scan: + /scan/{{id}}: post: operationId: Scan parameters: - - name: properties +{paramYaml} + responses: + "200": + description: OK +components: + schemas: {{}} +""" + +let private paramType types name = + (findMethod types "Scan").Value.GetParameters() + |> Array.find(fun p -> p.Name = name) + |> _.ParameterType + +[] +let ``parameter with both schema and content prefers schema``() = + let types = + singleParamSchema + """ - name: properties in: header schema: type: string @@ -1809,55 +1834,81 @@ paths: type: object properties: stage-location: - type: string - responses: - "200": - description: OK -components: - schemas: {} -""" + type: string""" + |> compileTaskSchema - let ex = Assert.ThrowsAny(fun () -> compileTaskSchema invalidSchema |> ignore) + paramType types "properties" |> shouldEqual typeof - ex.Message - |> shouldContainText "cannot define both schema and content" + buildInvokeBody types "Scan" + |> containsMethodCall "toJsonParam" + |> shouldEqual false [] let ``content-typed parameter without schema compiles and falls back to obj``() = - let invalidSchema = - """openapi: "3.1.1" -info: - title: InvalidContentSchemaTest - version: "1.0.0" -paths: - /scan: - post: - operationId: Scan - parameters: - - name: properties + let types = + singleParamSchema + """ - name: properties in: header content: application/json: - examples: {} - responses: - "200": - description: OK -components: - schemas: {} -""" + examples: {}""" + |> compileTaskSchema - let types = compileTaskSchema invalidSchema - let method = (findMethod types "Scan").Value - let parameters = method.GetParameters() - let propertiesParam = parameters |> Array.find(fun p -> p.Name = "properties") - propertiesParam.ParameterType |> shouldEqual typeof + paramType types "properties" |> shouldEqual typeof + +[] +let ``non-JSON content parameter uses its schema and plain serialization``() = + let types = + singleParamSchema + """ - name: note + in: header + content: + text/plain: + schema: + type: string""" + |> compileTaskSchema + + paramType types "note" |> shouldEqual typeof + + let body = buildInvokeBody types "Scan" + body |> containsMethodCall "toParam" |> shouldEqual true + body |> containsMethodCall "toJsonParam" |> shouldEqual false + +[] +let ``content-typed json path and cookie parameters are percent-encoded``() = + let types = + singleParamSchema + """ - name: id + in: path + required: true + content: + application/json: + schema: + type: object + properties: + x: + type: string + - name: session + in: cookie + content: + application/json: + schema: + type: object + properties: + y: + type: string""" + |> compileTaskSchema + + let body = buildInvokeBody types "Scan" + body |> containsMethodCall "toEscapedJsonParam" |> shouldEqual true + body |> containsMethodCall "toJsonParam" |> shouldEqual false [] let ``content-typed parameter does not throw and is compiled using its content schema``() = let types = compileTaskSchema contentTypedHeaderParamSchema let method = (findMethod types "Scan").Value let parameters = method.GetParameters() - // properties (object, required by default since Required is unset -> false) + cancellationToken + // properties (optional header, since Required is unset) + cancellationToken let paramNames = parameters |> Array.map(fun p -> p.Name) paramNames |> shouldContain "properties" @@ -1868,30 +1919,12 @@ let ``content-typed parameter does not throw and is compiled using its content s [] let ``content-typed json header uses json parameter serialization in request generation``() = - let types = compileTaskSchema contentTypedHeaderParamSchema - let method = (findMethod types "Scan").Value - let invokeCode = getInvokeCode method - let parameters = method.GetParameters() - let propertiesParam = parameters |> Array.find(fun p -> p.Name = "properties") - - let thisExpr = Expr.Var(Var("this", method.DeclaringType)) - let propertiesExpr = Expr.Var(Var("properties", propertiesParam.ParameterType)) - let ctExpr = Expr.Var(Var("cancellationToken", typeof)) - let body = invokeCode [ thisExpr; propertiesExpr; ctExpr ] - - body |> containsMethodCall "toJsonParam" |> shouldEqual true + buildInvokeBody (compileTaskSchema contentTypedHeaderParamSchema) "Scan" + |> containsMethodCall "toJsonParam" + |> shouldEqual true [] let ``content-typed json query parameter uses json query serialization in request generation``() = - let types = compileTaskSchema contentTypedQueryParamSchema - let method = (findMethod types "Search").Value - let invokeCode = getInvokeCode method - let parameters = method.GetParameters() - let filtersParam = parameters |> Array.find(fun p -> p.Name = "filters") - - let thisExpr = Expr.Var(Var("this", method.DeclaringType)) - let filtersExpr = Expr.Var(Var("filters", filtersParam.ParameterType)) - let ctExpr = Expr.Var(Var("cancellationToken", typeof)) - let body = invokeCode [ thisExpr; filtersExpr; ctExpr ] - - body |> containsMethodCall "toJsonQueryParam" |> shouldEqual true + buildInvokeBody (compileTaskSchema contentTypedQueryParamSchema) "Search" + |> containsMethodCall "toJsonQueryParam" + |> shouldEqual true From e5b643f95fc6ef60bbb5693845d78a57b0150bfc Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sat, 3 Oct 2026 21:58:25 +0200 Subject: [PATCH 09/11] Fix content parameter serialization edge cases --- .../OperationCompiler.fs | 37 +++++++--- src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 18 ++--- .../RuntimeHelpersTests.fs | 49 ++++++++++++- .../Schema.OperationCompilationTests.fs | 69 +++++++++++++++++++ 4 files changed, 153 insertions(+), 20 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index 1b9b7e0a..4445ae48 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -72,17 +72,17 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, | _ -> failwith "Cannot extract toQueryParams MethodInfo" let toJsonParamMethod = - match <@@ RuntimeHelpers.toJsonParam null Unchecked.defaultof @@> with + match <@@ RuntimeHelpers.toJsonParam null Unchecked.defaultof false @@> with | Call(None, m, _) -> m | _ -> failwith "Cannot extract toJsonParam MethodInfo" let toEscapedJsonParamMethod = - match <@@ RuntimeHelpers.toEscapedJsonParam null Unchecked.defaultof @@> with + match <@@ RuntimeHelpers.toEscapedJsonParam null Unchecked.defaultof false @@> with | Call(None, m, _) -> m | _ -> failwith "Cannot extract toEscapedJsonParam MethodInfo" let toJsonQueryParamMethod = - match <@@ RuntimeHelpers.toJsonQueryParam "" null Unchecked.defaultof @@> with + match <@@ RuntimeHelpers.toJsonQueryParam "" null Unchecked.defaultof false @@> with | Call(None, m, _) -> m | _ -> failwith "Cannot extract toJsonQueryParam MethodInfo" @@ -125,10 +125,8 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, $"%s{par.Name}In%A{par.In}" let isJsonMediaType(mediaType: string) = - let t = mediaType.Split(';').[0].Trim() - - t.Equals(MediaTypes.ApplicationJson, StringComparison.OrdinalIgnoreCase) - || t.EndsWith("+json", StringComparison.OrdinalIgnoreCase) + mediaType.Equals(MediaTypes.ApplicationJson, StringComparison.OrdinalIgnoreCase) + || mediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) /// OpenAPI 3.x: a parameter's type comes from `schema` or from its single `content` entry. /// Returns (schema, serializeAsJson). `schema` wins if both are present (invalid, but seen in the wild). @@ -144,7 +142,22 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, else kv.Value.Schema - schema, isJsonMediaType kv.Key + let mediaType = kv.Key.Split(';').[0].Trim() + let serializeAsJson = isJsonMediaType mediaType + + let isPlainString = + mediaType.Equals(MediaTypes.TextPlain, StringComparison.OrdinalIgnoreCase) + && schema.Type = Nullable(JsonSchemaType.String) + && String.IsNullOrEmpty schema.Format + + if not serializeAsJson && not isPlainString then + failwithf + "Operation '%s' parameter '%s' uses unsupported content '%s'. Only JSON and unformatted text/plain strings are supported." + providedMethodName + p.Name + kv.Key + + schema, serializeAsJson let openApiParameters = [ @@ -411,16 +424,18 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, match resolveParam param with | _, true when escapeJson -> - Expr.Call(toEscapedJsonParamMethod, [ obj; this ]) + Expr.Call(toEscapedJsonParamMethod, [ obj; this; Expr.Value param.Required ]) + |> Expr.Cast + | _, true -> + Expr.Call(toJsonParamMethod, [ obj; this; Expr.Value param.Required ]) |> Expr.Cast - | _, true -> Expr.Call(toJsonParamMethod, [ obj; this ]) |> Expr.Cast | _, false -> Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast let coerceQueryString (param: IOpenApiParameter) name expr = let obj = Expr.Coerce(expr, typeof) if snd(resolveParam param) then - Expr.Call(toJsonQueryParamMethod, [ Expr.Value name; obj; this ]) + Expr.Call(toJsonQueryParamMethod, [ Expr.Value name; obj; this; Expr.Value param.Required ]) |> Expr.Cast<(string * string) list> else Expr.Call(toQueryParamsMethod, [ Expr.Value name; obj; this ]) diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 3a0864d5..5f2ba5c0 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -293,19 +293,21 @@ module RuntimeHelpers = else obj.ToString() - let toJsonParam (obj: obj) (client: Swagger.ProvidedApiClientBase) = - let value = unwrapOptionalValue obj - - if isNull value then null else client.Serialize value + let toJsonParam (obj: obj) (client: Swagger.ProvidedApiClientBase) (required: bool) = + // Only a missing optional argument is omitted; required null and Some null are JSON values. + if isNull obj && not required then + null + else + client.Serialize(unwrapOptionalValue obj) /// JSON-serialized and percent-encoded, for path segments and cookie values. - let toEscapedJsonParam (obj: obj) (client: Swagger.ProvidedApiClientBase) = - match toJsonParam obj client with + let toEscapedJsonParam (obj: obj) (client: Swagger.ProvidedApiClientBase) (required: bool) = + match toJsonParam obj client required with | null -> null | s -> Uri.EscapeDataString s - let toJsonQueryParam (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) = - let param = toJsonParam obj client + let toJsonQueryParam (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) (required: bool) = + let param = toJsonParam obj client required if isNull param then [] else [ name, param ] let toQueryParams (name: string) (obj: obj) (client: Swagger.ProvidedApiClientBase) = diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 00552920..ec95cf8a 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -269,15 +269,61 @@ module ToJsonParamTests = fileSize = 42L |}) stubClient + false result |> shouldContainText "\"stageLocation\":\"stage-a\"" result |> shouldContainText "\"fileSize\":42" [] let ``toJsonParam returns null for Option None``() = - let result = toJsonParam (box(None: string option)) stubClient + let result = toJsonParam (box(None: string option)) stubClient false result |> shouldEqual null + [] + [] + [] + let ``JSON null is sent for required parameters and omitted for missing optional parameters``(required: bool) = + let value = toJsonParam null stubClient required + let escaped = toEscapedJsonParam null stubClient required + let expected = if required then "null" else null + value |> shouldEqual expected + escaped |> shouldEqual expected + + use request = + createHttpRequest "GET" "/scan" (toJsonQueryParam "value" null stubClient required) + + fillHeadersAndCookies request [ "X-Value", value ] [ "session", escaped ] + + request.RequestUri.OriginalString + |> shouldEqual(if required then "scan?value=null" else "scan") + + request.Headers.Contains "X-Value" |> shouldEqual required + request.Headers.Contains "Cookie" |> shouldEqual required + + if required then + request.Headers.GetValues "X-Value" + |> Seq.toList + |> shouldEqual [ "null" ] + + request.Headers.GetValues "Cookie" + |> Seq.toList + |> shouldEqual [ "session=null" ] + + [] + let ``toJsonQueryParam preserves explicitly present optional null``() = + toJsonQueryParam "value" (box(Some(null: string))) stubClient false + |> shouldEqual [ "value", "null" ] + + [] + let ``toJsonParam unwraps Some before serialization``() = + toJsonParam (box(Some "hello")) stubClient false + |> shouldEqual "\"hello\"" + + [] + let ``toEscapedJsonParam encodes JSON strings``() = + toEscapedJsonParam (box "a/b?c;d") stubClient true + |> shouldEqual "%22a%2Fb%3Fc%3Bd%22" + [] let ``toJsonQueryParam keeps content-typed values as a single serialized pair``() = let result = @@ -289,6 +335,7 @@ module ToJsonParamTests = fileSize = 42L |}) stubClient + false result |> shouldHaveLength 1 fst result[0] |> shouldEqual "properties" diff --git a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs index 68ac7e60..12588a1f 100644 --- a/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs +++ b/tests/SwaggerProvider.Tests/Schema.OperationCompilationTests.fs @@ -1874,6 +1874,75 @@ let ``non-JSON content parameter uses its schema and plain serialization``() = body |> containsMethodCall "toParam" |> shouldEqual true body |> containsMethodCall "toJsonParam" |> shouldEqual false +[] +[] +[] +[] +[] +[] +[] +[] +[] +let ``unsupported content parameters fail during compilation`` (mediaType: string) (schemaYaml: string) = + let schema = + singleParamSchema + $""" - name: value + in: query + content: + {mediaType}: + schema: + {schemaYaml}""" + + let ex = Assert.Throws(fun () -> compileTaskSchema schema |> ignore) + ex.Message |> shouldContainText "Scan" + ex.Message |> shouldContainText "value" + ex.Message |> shouldContainText mediaType + ex.Message |> shouldContainText "unsupported content" + +[] +[] +[] +[] +[] +[] +[] +[] +let ``JSON parameter serialization receives requiredness`` (location: string) (required: bool) = + let requiredYaml = if required then "true" else "false" + + let types = + singleParamSchema + $""" - name: id + in: {location} + required: {requiredYaml} + content: + application/json: + schema: + type: 'null'""" + |> compileTaskSchema + + paramType types "id" |> shouldEqual typeof + + let helperName = + match location with + | "query" -> "toJsonQueryParam" + | "header" -> "toJsonParam" + | _ -> "toEscapedJsonParam" + + let rec hasRequiredArgument expr = + match expr with + | Call(_, m, args) when m.Name = helperName -> + match List.last args with + | Value(:? bool as actual, _) -> actual = required + | _ -> false + | ShapeVar _ -> false + | ShapeLambda(_, body) -> hasRequiredArgument body + | ShapeCombination(_, args) -> args |> List.exists hasRequiredArgument + + buildInvokeBody types "Scan" + |> hasRequiredArgument + |> shouldEqual true + [] let ``content-typed json path and cookie parameters are percent-encoded``() = let types = From 5ef0f8d98e6368ce852a5b3a8e7c25531b3449a5 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sat, 3 Oct 2026 22:24:34 +0200 Subject: [PATCH 10/11] Fix condition for isPlainString check Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/SwaggerProvider.DesignTime/OperationCompiler.fs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index 4445ae48..b99e0d1e 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -147,7 +147,8 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let isPlainString = mediaType.Equals(MediaTypes.TextPlain, StringComparison.OrdinalIgnoreCase) - && schema.Type = Nullable(JsonSchemaType.String) + && (schema.Type = Nullable(JsonSchemaType.String) + || schema.Type = Nullable(JsonSchemaType.String ||| JsonSchemaType.Null)) && String.IsNullOrEmpty schema.Format if not serializeAsJson && not isPlainString then From 89b2811ad33416573330743f32e807d37ae16cc7 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Sat, 3 Oct 2026 22:37:07 +0200 Subject: [PATCH 11/11] Fix parallel design-time restore race --- .../SwaggerProvider.Runtime.fsproj | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/SwaggerProvider.Runtime/SwaggerProvider.Runtime.fsproj b/src/SwaggerProvider.Runtime/SwaggerProvider.Runtime.fsproj index 11ec6e8f..384137bb 100644 --- a/src/SwaggerProvider.Runtime/SwaggerProvider.Runtime.fsproj +++ b/src/SwaggerProvider.Runtime/SwaggerProvider.Runtime.fsproj @@ -1,6 +1,5 @@ - Library @@ -29,12 +28,8 @@ + + - - - - - -