diff --git a/src/SwaggerProvider.DesignTime/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/OperationCompiler.fs index fb2f1fa7..b99e0d1e 100644 --- a/src/SwaggerProvider.DesignTime/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/OperationCompiler.fs @@ -71,6 +71,21 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, | Call(None, m, _) -> m | _ -> failwith "Cannot extract toQueryParams MethodInfo" + let toJsonParamMethod = + 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 false @@> with + | Call(None, m, _) -> m + | _ -> failwith "Cannot extract toEscapedJsonParam MethodInfo" + + let toJsonQueryParamMethod = + match <@@ RuntimeHelpers.toJsonQueryParam "" null Unchecked.defaultof false @@> 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 +124,42 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let unambiguousName(par: IOpenApiParameter) = $"%s{par.Name}In%A{par.In}" + let isJsonMediaType(mediaType: string) = + 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). + let resolveParam(p: IOpenApiParameter) = + if not(isNull p.Schema) || isNull p.Content || p.Content.Count = 0 then + p.Schema, false + else + let kv = Seq.head p.Content // spec requires exactly one entry + + let schema = + if isNull kv.Value.Schema then + OpenApiSchema() :> IOpenApiSchema + else + kv.Value.Schema + + let mediaType = kv.Key.Split(';').[0].Trim() + let serializeAsJson = isJsonMediaType mediaType + + let isPlainString = + mediaType.Equals(MediaTypes.TextPlain, StringComparison.OrdinalIgnoreCase) + && (schema.Type = Nullable(JsonSchemaType.String) + || schema.Type = Nullable(JsonSchemaType.String ||| JsonSchemaType.Null)) + && 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 = [ if not(isNull pathItem.Parameters) then @@ -204,7 +255,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 (fst(resolveParam current)) current.Required let providedParam = if current.Required then @@ -367,15 +418,29 @@ 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 = + // 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) - Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast - let rec coerceQueryString name expr = + match resolveParam param with + | _, true when escapeJson -> + Expr.Call(toEscapedJsonParamMethod, [ obj; this; Expr.Value param.Required ]) + |> Expr.Cast + | _, true -> + Expr.Call(toJsonParamMethod, [ obj; this; Expr.Value param.Required ]) + |> Expr.Cast + | _, false -> Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast + + let coerceQueryString (param: IOpenApiParameter) name expr = let obj = Expr.Coerce(expr, typeof) - Expr.Call(toQueryParamsMethod, [ Expr.Value name; obj; this ]) - |> Expr.Cast<(string * string) list> + if snd(resolveParam param) then + 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 ]) + |> Expr.Cast<(string * string) list> // Partitions arguments based on their locations let path, queryParamLists, headers, cookies = @@ -387,19 +452,19 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, match param.In.Value with | ParameterLocation.Path -> - let value = coerceString valueExpr + let value = coerceString true 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 false param valueExpr let headers' = <@ (name, %value) :: (%headers) @> (path, queryParamLists, headers', cookies) | ParameterLocation.Cookie -> - let value = coerceString valueExpr + let value = coerceString true param valueExpr let cookies' = <@ (name, %value) :: (%cookies) @> (path, queryParamLists, headers, cookies') | x -> failwithf $"Unsupported parameter location '%A{x}'" @@ -545,10 +610,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 fst(resolveParam 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..5f2ba5c0 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,23 @@ module RuntimeHelpers = else obj.ToString() + 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) (required: bool) = + match toJsonParam obj client required with + | null -> null + | s -> Uri.EscapeDataString s + + 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) = if isNull obj then [] 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 @@ + + - - - - - - diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index ff8f398a..ec95cf8a 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -248,6 +248,100 @@ 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 + 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 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 = + toJsonQueryParam + "properties" + (box + {| + stageLocation = "stage-a" + fileSize = 42L + |}) + stubClient + false + + 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..12588a1f 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,290 @@ 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: {} +""" + +/// 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: ParameterTest + version: "1.0.0" +paths: + /scan/{{id}}: + post: + operationId: Scan + parameters: +{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 + content: + application/json: + schema: + type: object + properties: + stage-location: + type: string""" + |> compileTaskSchema + + paramType types "properties" |> shouldEqual typeof + + buildInvokeBody types "Scan" + |> containsMethodCall "toJsonParam" + |> shouldEqual false + +[] +let ``content-typed parameter without schema compiles and falls back to obj``() = + let types = + singleParamSchema + """ - name: properties + in: header + content: + application/json: + examples: {}""" + |> compileTaskSchema + + 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 ``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 = + 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 (optional header, since Required is unset) + 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``() = + buildInvokeBody (compileTaskSchema contentTypedHeaderParamSchema) "Scan" + |> containsMethodCall "toJsonParam" + |> shouldEqual true + +[] +let ``content-typed json query parameter uses json query serialization in request generation``() = + buildInvokeBody (compileTaskSchema contentTypedQueryParamSchema) "Search" + |> containsMethodCall "toJsonQueryParam" + |> shouldEqual true