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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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\""