Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 78 additions & 14 deletions src/SwaggerProvider.DesignTime/OperationCompiler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProvidedApiClientBase> false @@> with
| Call(None, m, _) -> m
| _ -> failwith "Cannot extract toJsonParam MethodInfo"

let toEscapedJsonParamMethod =
match <@@ RuntimeHelpers.toEscapedJsonParam null Unchecked.defaultof<ProvidedApiClientBase> false @@> with
| Call(None, m, _) -> m
| _ -> failwith "Cannot extract toEscapedJsonParam MethodInfo"

let toJsonQueryParamMethod =
match <@@ RuntimeHelpers.toJsonQueryParam "" null Unchecked.defaultof<ProvidedApiClientBase> 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 ->
Expand Down Expand Up @@ -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
Comment thread
Copilot marked this conversation as resolved.

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<obj>)
Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast<string>

let rec coerceQueryString name expr =
match resolveParam param with
| _, true when escapeJson ->
Expr.Call(toEscapedJsonParamMethod, [ obj; this; Expr.Value param.Required ])
|> Expr.Cast<string>
| _, true ->
Expr.Call(toJsonParamMethod, [ obj; this; Expr.Value param.Required ])
|> Expr.Cast<string>
| _, false -> Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast<string>

let coerceQueryString (param: IOpenApiParameter) name expr =
let obj = Expr.Coerce(expr, typeof<obj>)

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 =
Expand All @@ -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}'"
Expand Down Expand Up @@ -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

Expand Down
37 changes: 37 additions & 0 deletions src/SwaggerProvider.Runtime/RuntimeHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,26 @@ module RuntimeHelpers =
let private enumSerializerFactory =
System.Func<Type, obj -> string>(buildEnumSerializer)

let rec private unwrapOptionalValue(obj: obj) =
if isNull obj then
null
else
let ty = obj.GetType()

if
ty.IsGenericType
&& ty.GetGenericTypeDefinition() = typedefof<option<_>>
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")
Expand Down Expand Up @@ -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
[]
Expand Down
9 changes: 2 additions & 7 deletions src/SwaggerProvider.Runtime/SwaggerProvider.Runtime.fsproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<!-- See https://stackoverflow.com/questions/43921992/how-can-i-use-beforebuild-and-afterbuild-targets-with-visual-studio-2017 -->
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk" />
<PropertyGroup>
<OutputType>Library</OutputType>
Expand Down Expand Up @@ -29,12 +28,8 @@
<Compile Include="ProvidedApiClientBase.fs" />
<Compile Include="RuntimeHelpers.fs" />
<None Include="paket.references" />
<!-- Let MSBuild coordinate restore/build without referencing the design-time assembly from the runtime. -->
<ProjectReference Include="..\SwaggerProvider.DesignTime\SwaggerProvider.DesignTime.fsproj" ReferenceOutputAssembly="false" PrivateAssets="all" />
</ItemGroup>
<Target Name="BeforeBuild">
<MSBuild Projects="..\SwaggerProvider.DesignTime\SwaggerProvider.DesignTime.fsproj" Targets="Restore" />
<MSBuild Projects="..\SwaggerProvider.DesignTime\SwaggerProvider.DesignTime.fsproj" Targets="Build" Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework)" />
</Target>
<Target Name="AfterBuild">
</Target>
<Import Project="..\..\.paket\Paket.Restore.targets" />
</Project>
94 changes: 94 additions & 0 deletions tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

[<Fact>]
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"

[<Fact>]
let ``toJsonParam returns null for Option None``() =
let result = toJsonParam (box(None: string option)) stubClient false
result |> shouldEqual null

[<Theory>]
[<InlineData(false)>]
[<InlineData(true)>]
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" ]

[<Fact>]
let ``toJsonQueryParam preserves explicitly present optional null``() =
toJsonQueryParam "value" (box(Some(null: string))) stubClient false
|> shouldEqual [ "value", "null" ]

[<Fact>]
let ``toJsonParam unwraps Some before serialization``() =
toJsonParam (box(Some "hello")) stubClient false
|> shouldEqual "\"hello\""

[<Fact>]
let ``toEscapedJsonParam encodes JSON strings``() =
toEscapedJsonParam (box "a/b?c;d") stubClient true
|> shouldEqual "%22a%2Fb%3Fc%3Bd%22"

[<Fact>]
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 =
Expand Down
Loading
Loading