Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
7 changes: 6 additions & 1 deletion build.fsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,12 @@ Target.createFinal "StopServer" (fun _ ->
//Process.killAllByName "dotnet"
)

Target.create "BuildTests" (fun _ -> dotnet "build" "SwaggerProvider.TestsAndDocs.sln -c Release")
Target.create "BuildTests" (fun _ ->
// Explicit restore ensures project.assets.json has all target frameworks before the build.
// Without this, the inner-build restores triggered by Paket.Restore.targets may overwrite
// the assets file with only one TFM, causing NETSDK1005 for the other TFM.
dotnet "restore" "SwaggerProvider.TestsAndDocs.sln"
dotnet "build" "SwaggerProvider.TestsAndDocs.sln -c Release --no-restore")

// --------------------------------------------------------------------------------------
// Run the unit tests using test runner
Expand Down
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.200",
"version": "10.0.102",
"rollForward": "latestMinor"
}
}
83 changes: 61 additions & 22 deletions src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ type PayloadType =

/// Object for compiling operations.
type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, ignoreControllerPrefix, ignoreOperationId, asAsync: bool) =
let compileOperation (providedMethodName: string) (apiCall: ApiCall) =
let compileOperation (providedMethodName: string) (apiCall: ApiCall) (includeCancellationToken: bool) =
let path, pathItem, opTy = apiCall
let operation = pathItem.Operations[opTy]

Expand Down Expand Up @@ -96,7 +96,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
let (|NoMediaType|_|)(content: IDictionary<string, OpenApiMediaType>) =
if isNull content || content.Count = 0 then Some() else None

let payloadMime, parameters =
let payloadMime, parameters, ctArgIndex =
/// handles de-duplicating Swagger parameter names if the same parameter name
/// appears in multiple locations in a given operation definition.
let uniqueParamName usedNames (param: IOpenApiParameter) =
Expand Down Expand Up @@ -147,18 +147,15 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,

let payloadTy = bodyFormatAndParam |> Option.map fst |> Option.defaultValue NoData

let orderedParameters =
let required, optional =
[ yield! openApiParameters
if bodyFormatAndParam.IsSome then
yield bodyFormatAndParam.Value |> snd ]
|> List.distinctBy(fun op -> op.Name, op.In)
|> List.partition(_.Required)
let requiredOpenApiParams, optionalOpenApiParams =
[ yield! openApiParameters
if bodyFormatAndParam.IsSome then
yield bodyFormatAndParam.Value |> snd ]
|> List.distinctBy(fun op -> op.Name, op.In)
|> List.partition(_.Required)

List.append required optional

let providedParameters =
((Set.empty, []), orderedParameters)
let buildProvidedParameters usedNames (paramList: IOpenApiParameter list) =
((usedNames, []), paramList)
||> List.fold(fun (names, parameters) current ->
let names, paramName = uniqueParamName names current

Expand All @@ -173,12 +170,30 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
ProvidedParameter(paramName, paramType, false, paramDefaultValue)

(names, providedParam :: parameters))
|> snd
// because we built up our list in reverse order with the fold,
// reverse it again so that all required properties come first
|> List.rev
|> fun (finalNames, ps) -> finalNames, List.rev ps

let namesAfterRequired, requiredProvidedParams =
buildProvidedParameters Set.empty requiredOpenApiParams

let _, optionalProvidedParams =
buildProvidedParameters namesAfterRequired optionalOpenApiParams

let ctArgIndex, parameters =
if includeCancellationToken then
let scope = UniqueNameGenerator()

(requiredProvidedParams @ optionalProvidedParams)
|> List.iter(fun p -> scope.MakeUnique p.Name |> ignore)

let ctName = scope.MakeUnique "cancellationToken"
let ctParam = ProvidedParameter(ctName, typeof<Threading.CancellationToken>)
// CT is inserted after required params so it never follows optional params
let ctArgIndex = List.length requiredProvidedParams
ctArgIndex, requiredProvidedParams @ [ ctParam ] @ optionalProvidedParams
else
-1, requiredProvidedParams @ optionalProvidedParams

payloadTy.ToMediaType(), providedParameters
payloadTy.ToMediaType(), parameters, ctArgIndex

// find the inner type value
let retMimeAndTy =
Expand Down Expand Up @@ -264,8 +279,26 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
// Locates parameters matching the arguments
let mutable payloadExp = None

// When the CancellationToken overload is generated, CT is inserted at ctArgIndex
// (after required params, before optional params). Extract it by that known index
// to avoid name-collision issues and invalid Expr.Coerce on a struct type.
let apiArgs, ct =
let allArgs = List.tail args // skip `this`

if includeCancellationToken then
let ctArg = List.item ctArgIndex allArgs

let apiArgs =
allArgs
|> List.indexed
|> List.choose(fun (i, a) -> if i = ctArgIndex then None else Some a)

apiArgs, Expr.Cast<Threading.CancellationToken>(ctArg)
else
allArgs, <@ Threading.CancellationToken.None @>

let parameters =
List.tail args // skip `this` param
apiArgs
|> List.choose (function
| ShapeVar sVar as expr ->
let param =
Expand Down Expand Up @@ -392,7 +425,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
@>

let action =
<@ (%this).CallAsync(%httpRequestMessageWithPayload, errorCodes, errorDescriptions) @>
<@ (%this).CallAsync(%httpRequestMessageWithPayload, errorCodes, errorDescriptions, %ct) @>

let responseObj =
let innerReturnType = defaultArg retTy null
Expand Down Expand Up @@ -591,13 +624,19 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
let methodNameScope = UniqueNameGenerator()

operations
|> List.map(fun op ->
|> List.collect(fun op ->
let skipLength =
if String.IsNullOrEmpty clientName then
0
else
clientName.Length + 1

let name = OperationCompiler.GetMethodNameCandidate op skipLength ignoreOperationId
compileOperation (methodNameScope.MakeUnique name) op)
let uniqueName = methodNameScope.MakeUnique name
// Generate two overloads: one without CancellationToken (backward compatible)
// and one with an explicit CancellationToken parameter.
// We cannot use an optional struct parameter with a default value because
// struct values (e.g., CancellationToken.None) cannot be stored in DefaultParameterValue
// custom attributes.
[ compileOperation uniqueName op false; compileOperation uniqueName op true ])
|> ty.AddMembers)
7 changes: 6 additions & 1 deletion src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,13 @@ type ProvidedApiClientBase(httpClient: HttpClient, options: JsonSerializerOption
JsonSerializer.Deserialize(value, retTy, options)

member this.CallAsync(request: HttpRequestMessage, errorCodes: string[], errorDescriptions: string[]) : Task<HttpContent> =
this.CallAsync(request, errorCodes, errorDescriptions, System.Threading.CancellationToken.None)

member this.CallAsync
(request: HttpRequestMessage, errorCodes: string[], errorDescriptions: string[], cancellationToken: System.Threading.CancellationToken)
: Task<HttpContent> =
task {
let! response = this.HttpClient.SendAsync(request)
let! response = this.HttpClient.SendAsync(request, cancellationToken)

if response.IsSuccessStatusCode then
return response.Content
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<Compile Include="v3\Swagger.NullableDate.Tests.fs" />
<Compile Include="v3\Swagger.SchemaReaderErrors.Tests.fs" />
<Compile Include="v3\Swashbuckle.ReturnControllers.Tests.fs" />
<Compile Include="v3\Swashbuckle.CancellationToken.Tests.fs" />
<Compile Include="v3\Swashbuckle.ReturnTextControllers.Tests.fs" />
<Compile Include="v3\Swashbuckle.UpdateControllers.Tests.fs" />
<Compile Include="v3\Swashbuckle.ResourceControllers.Tests.fs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module Swashbuckle.v3.CancellationTokenTests

open Xunit
open FsUnitTyped
open System
open System.Threading
open Swashbuckle.v3.ReturnControllersTests

[<Fact>]
let ``Call generated method with explicit CancellationToken None``() =
task {
let! result = api.GetApiReturnBoolean(CancellationToken.None)
result |> shouldEqual true
}

[<Fact>]
let ``Call generated method with valid CancellationTokenSource token``() =
task {
use cts = new CancellationTokenSource()
let! result = api.GetApiReturnInt32(cts.Token)
result |> shouldEqual 42
}

[<Fact>]
let ``Call generated method with already-cancelled token raises OperationCanceledException``() =
task {
use cts = new CancellationTokenSource()
cts.Cancel()

try
let! _ = api.GetApiReturnString(cts.Token)
failwith "Expected OperationCanceledException"
with
| :? OperationCanceledException -> ()
| :? System.AggregateException as aex when (aex.InnerException :? OperationCanceledException) -> ()
}

[<Fact>]
let ``Call POST generated method with explicit CancellationToken None``() =
task {
let! result = api.PostApiReturnString(CancellationToken.None)
result |> shouldEqual "Hello world"
}
34 changes: 33 additions & 1 deletion tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,8 @@ module ToContentTests =
type private StubHttpMessageHandler(statusCode: HttpStatusCode, responseBody: string) =
inherit HttpMessageHandler()

override _.SendAsync(_request: HttpRequestMessage, _cancellationToken: CancellationToken) =
override _.SendAsync(_request: HttpRequestMessage, cancellationToken: CancellationToken) =
cancellationToken.ThrowIfCancellationRequested()
let response = new HttpResponseMessage(statusCode)
response.Content <- new StringContent(responseBody)
Task.FromResult(response)
Expand Down Expand Up @@ -495,3 +496,34 @@ module OpenApiExceptionTests =

()
}

[<Fact>]
let ``CallAsync with CancellationToken returns content on success``() =
task {
use handler = new StubHttpMessageHandler(HttpStatusCode.OK, "result")
let client = makeClient handler
use request = new HttpRequestMessage(HttpMethod.Get, "http://stub/pets/1")
let! content = client.CallAsync(request, [||], [||], CancellationToken.None)
let! body = content.ReadAsStringAsync()
body |> shouldEqual "result"
}

[<Fact>]
let ``CallAsync with already-cancelled token raises OperationCanceledException``() =
task {
use cts = new CancellationTokenSource()
cts.Cancel()

use handler = new StubHttpMessageHandler(HttpStatusCode.OK, "ok")
let client = makeClient handler
use request = new HttpRequestMessage(HttpMethod.Get, "http://stub/pets/1")

let! _ =
Assert.ThrowsAnyAsync<OperationCanceledException>(fun () ->
task {
let! _ = client.CallAsync(request, [||], [||], cts.Token)
()
})

()
}
Loading