Skip to content
Open
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
123 changes: 109 additions & 14 deletions src/SwaggerProvider.DesignTime/OperationCompiler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProvidedApiClientBase> @@> with
| Call(None, m, _) -> m
| _ -> failwith "Cannot extract toJsonParam MethodInfo"

let toJsonQueryParamMethod =
match <@@ RuntimeHelpers.toJsonQueryParam "" null Unchecked.defaultof<ProvidedApiClientBase> @@> 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 +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"
Comment on lines +140 to +149

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

let rec coerceQueryString name expr =
match resolveParamContentMediaType param with
| Some _ -> Expr.Call(toJsonParamMethod, [ obj; this ]) |> Expr.Cast<string>
| None -> Expr.Call(toParamMethod, [ obj ]) |> Expr.Cast<string>

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

Expand Down
29 changes: 29 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,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
[]
Expand Down
47 changes: 47 additions & 0 deletions tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

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

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

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