diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index fb2f1fa7..ac4895f5 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,6 +119,84 @@ 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) = + 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 -> + 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 + let openApiParameters = [ if not(isNull pathItem.Parameters) then @@ -204,7 +292,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 @@ -367,15 +455,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 = @@ -387,19 +483,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}'" @@ -545,10 +641,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/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index 6b0abaee..1694b3b6 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,15 @@ 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..00552920 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -248,6 +248,53 @@ 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 0bd9095a..a838d440 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 = @@ -1700,3 +1710,188 @@ 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 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 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 + in: header + content: + application/json: + examples: {} + responses: + "200": + description: OK +components: + schemas: {} +""" + + 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``() = + 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 + +[] +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