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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Git Policy

- **NEVER commit or push unless the user explicitly asks you to.** Only create commits when directly requested.

## Build, Test & Lint Commands

- **Build**: `dotnet fake build -t Build` (Release configuration)
Expand Down
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"
}
}
6 changes: 5 additions & 1 deletion src/SwaggerProvider.DesignTime/Utils.fs
Original file line number Diff line number Diff line change
Expand Up @@ -332,9 +332,13 @@ module SchemaReader =
resolvedPath
}

type UniqueNameGenerator() =
type UniqueNameGenerator(?occupiedNames: string seq) =
let hash = System.Collections.Generic.HashSet<_>()

do
for name in (defaultArg occupiedNames Seq.empty) do
hash.Add(name.ToLowerInvariant()) |> ignore

let rec findUniq prefix i =
let newName = sprintf "%s%s" prefix (if i = 0 then "" else i.ToString())
let key = newName.ToLowerInvariant()
Expand Down
79 changes: 56 additions & 23 deletions src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs
Original file line number Diff line number Diff line change
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,32 @@ 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 =
let scope = UniqueNameGenerator()

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

let ctName = scope.MakeUnique "cancellationToken"

payloadTy.ToMediaType(), providedParameters
let ctParam =
ProvidedParameter(ctName, typeof<Threading.CancellationToken>, false, null)
// CT is appended last to preserve existing positional argument calls
let ctArgIndex =
List.length requiredProvidedParams
+ List.length optionalProvidedParams

ctArgIndex, requiredProvidedParams @ optionalProvidedParams @ [ ctParam ]

payloadTy.ToMediaType(), parameters, ctArgIndex

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

// CT is inserted at ctArgIndex. Extract it by position.
let apiArgs, ct =
let allArgs = List.tail args // skip `this`
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)

let parameters =
List.tail args // skip `this` param
apiArgs
|> List.choose (function
| ShapeVar sVar as expr ->
let param =
Expand Down Expand Up @@ -392,39 +421,42 @@ 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

<@
let x = %action
let ct = %ct

task {
let! response = x
let! content = response.ReadAsStringAsync()
let! content = RuntimeHelpers.readContentAsString response ct
return (%this).Deserialize(content, innerReturnType)
}
@>

let responseStream =
<@
let x = %action
let ct = %ct

task {
let! response = x
let! data = response.ReadAsStreamAsync()
let! data = RuntimeHelpers.readContentAsStream response ct
return data
}
@>

let responseString =
<@
let x = %action
let ct = %ct

task {
let! response = x
let! data = response.ReadAsStringAsync()
let! data = RuntimeHelpers.readContentAsString response ct
return data
}
@>
Expand Down Expand Up @@ -599,5 +631,6 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
clientName.Length + 1

let name = OperationCompiler.GetMethodNameCandidate op skipLength ignoreOperationId
compileOperation (methodNameScope.MakeUnique name) op)
let uniqueName = methodNameScope.MakeUnique name
compileOperation uniqueName op)
|> ty.AddMembers)
10 changes: 8 additions & 2 deletions src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ type ProvidedApiClientBase(httpClient: HttpClient, options: JsonSerializerOption
default _.Deserialize(value, retTy: Type) : obj =
JsonSerializer.Deserialize(value, retTy, options)

member this.CallAsync(request: HttpRequestMessage, errorCodes: string[], errorDescriptions: string[]) : Task<HttpContent> =
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 All @@ -61,7 +63,11 @@ type ProvidedApiClientBase(httpClient: HttpClient, options: JsonSerializerOption
let! body =
task {
try
#if NET5_0_OR_GREATER
return! response.Content.ReadAsStringAsync(cancellationToken)
#else
return! response.Content.ReadAsStringAsync()
#endif
with _ ->
// If reading the body fails (e.g., disposed stream or invalid charset),
// fall back to an empty body so we can still throw OpenApiException.
Expand Down
14 changes: 14 additions & 0 deletions src/SwaggerProvider.Runtime/RuntimeHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,20 @@ module RuntimeHelpers =

castFn.MakeGenericMethod([| runtimeTy |]).Invoke(null, [| asyncOp |])

let readContentAsString (content: HttpContent) (ct: System.Threading.CancellationToken) : Task<string> =
#if NET5_0_OR_GREATER
content.ReadAsStringAsync(ct)
#else
content.ReadAsStringAsync()
#endif

let readContentAsStream (content: HttpContent) (ct: System.Threading.CancellationToken) : Task<IO.Stream> =
#if NET5_0_OR_GREATER
content.ReadAsStreamAsync(ct)
#else
content.ReadAsStreamAsync()
#endif

let taskCast runtimeTy (task: Task<obj>) =
let castFn = typeof<TaskExtensions>.GetMethod "cast"

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,138 @@
module Swashbuckle.v3.CancellationTokenTests

open Xunit
open FsUnitTyped
open System
open System.Net.Http
open System.Threading
open SwaggerProvider
open Swashbuckle.v3.ReturnControllersTests

type WebAPIAsync =
OpenApiClientProvider<"http://localhost:5000/swagger/v1/openapi.json", IgnoreOperationId=true, SsrfProtection=false, PreferAsync=true>

let apiAsync =
let handler = new HttpClientHandler(UseCookies = false)

let client =
new HttpClient(handler, true, BaseAddress = Uri("http://localhost:5000"))

WebAPIAsync.Client(client)

[<Fact>]
let ``Call generated method without CancellationToken uses default token``() =
task {
let! result = api.GetApiReturnBoolean()
result |> shouldEqual true
}

[<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"
}

[<Fact>]
let ``Call async generated method without CancellationToken uses default token``() =
async {
let! result = apiAsync.GetApiReturnBoolean()
result |> shouldEqual true
}
|> Async.StartAsTask

[<Fact>]
let ``Call method with required param and explicit CancellationToken``() =
task {
use cts = new CancellationTokenSource()
let! result = api.GetApiUpdateString("Serge", cts.Token)
result |> shouldEqual "Hello, Serge"
}

[<Fact>]
let ``Call method with optional param and explicit CancellationToken``() =
task {
use cts = new CancellationTokenSource()
let! result = api.GetApiUpdateBool(Some true, cts.Token)
result |> shouldEqual false
}

[<Fact>]
let ``Call async generated method with explicit CancellationToken``() =
async {
use cts = new CancellationTokenSource()
let! result = apiAsync.GetApiReturnInt32(cts.Token)
result |> shouldEqual 42
}
|> Async.StartAsTask

[<Fact>]
let ``Call stream-returning method with explicit CancellationToken``() =
task {
use cts = new CancellationTokenSource()
let! result = api.GetApiReturnFile(cts.Token)
use reader = new IO.StreamReader(result)
let! content = reader.ReadToEndAsync()
content |> shouldEqual "I am totally a file's\ncontent"
}

[<Fact>]
let ``Call text-returning method with explicit CancellationToken``() =
task {
use cts = new CancellationTokenSource()
let! result = api.GetApiReturnPlain(cts.Token)
result |> shouldEqual "Hello world"
}

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

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

[<Fact>]
let ``Call async POST generated method with explicit CancellationToken``() =
async {
use cts = new CancellationTokenSource()
let! result = apiAsync.PostApiReturnString(cts.Token)
result |> shouldEqual "Hello world"
}
|> Async.StartAsTask
Loading
Loading