Skip to content

Commit 168908d

Browse files
github-actions[bot]CopilotCopilotsergey-tihon
authored
[Repo Assist] feat: add CancellationToken support to OpenApiClientProvider generated methods (closes #212) (#336)
* feat: add CancellationToken support to OpenApiClientProvider generated methods (closes #212) - Add CallAsync overload with CancellationToken to ProvidedApiClientBase - Thread CancellationToken from generated methods through to HttpClient.SendAsync - Each generated method gains an optional cancellationToken parameter (defaults to CancellationToken.None) - Backward-compatible: existing call sites without CT continue to work unchanged - Add unit tests: success with CancellationToken.None, cancellation propagation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger checks * fix: replace optional struct CancellationToken parameter with method overloads (#337) * Initial plan * Fix: revert global.json and address CancellationToken build failures Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/1861c3cb-6a0a-438a-aa31-f65b8c809f88 * fix: use method overloading for CancellationToken support instead of optional struct parameter Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/1861c3cb-6a0a-438a-aa31-f65b8c809f88 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Add type provider integration tests for CancellationToken-overloaded methods (#338) * Fix: CT parameter name uniqueness in CancellationToken overload generation (#339) * Initial plan * fix: generate unique CT parameter name to avoid collision with OpenAPI params named 'cancellationToken' Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/7d588ec7-c4df-4a6c-89f8-9c13c2472d29 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * Fix CancellationToken parameter ordering and name collision in v3 OperationCompiler (#341) * Initial plan * fix: insert CT between required and optional params; generate unique CT name Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/b0c519de-0186-40ca-8174-42ed67a5316a * fix: add explicit restore + --no-restore to BuildTests to fix NETSDK1005 Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> Agent-Logs-Url: https://github.com/fsprojects/SwaggerProvider/sessions/565d6633-576d-4587-b924-a29b0ea53c2c --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> * refactor: use UniqueNameGenerator for CT param name uniqueness Replace hand-coded recursive findUniqueName function with the existing UniqueNameGenerator utility (already used in DefinitionCompiler and for method name deduplication in OperationCompiler). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add optional occupiedNames parameter to UniqueNameGenerator constructor Allows callers to pre-seed the generator with names that are already taken, so MakeUnique will never return any of those names without a numeric suffix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: single CallAsync overload + single generated method with optional CancellationToken - Remove no-CT CallAsync overload from ProvidedApiClientBase; keep only the version with explicit CancellationToken (quotation code always supplies it) - Remove double-compilation in OperationCompiler: one method per operation with optional cancellationToken (null default = default(CancellationToken).None) - Update RuntimeHelpersTests to pass CancellationToken.None explicitly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cleanup: simplify OperationCompiler and add default/async CT tests - Use List.map instead of List.collect since compileOperation returns a single method - Clean up comments in OperationCompiler - Add test for calling generated method without CancellationToken (default token) - Add test for async (PreferAsync=true) generated method without CancellationToken * feat: propagate CancellationToken through ReadAsStringAsync/ReadAsStreamAsync via RuntimeHelpers Add readContentAsString and readContentAsStream wrappers to RuntimeHelpers with #if NET5_0_OR_GREATER guards, enabling CancellationToken propagation in generated quotation code that must compile against netstandard2.0. Also add explicit CancellationToken integration tests and conditional CT support in ProvidedApiClientBase error path. * test: add CT coverage for stream, text/plain, async cancellation, and async POST paths * fix: async tests --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: sergey-tihon <1197905+sergey-tihon@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Sergey Tihon <sergey.tihon@gmail.com>
1 parent ff3a9dd commit 168908d

File tree

11 files changed

+291
-32
lines changed

11 files changed

+291
-32
lines changed

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## Git Policy
2+
3+
- **NEVER commit or push unless the user explicitly asks you to.** Only create commits when directly requested.
4+
15
## Build, Test & Lint Commands
26

37
- **Build**: `dotnet fake build -t Build` (Release configuration)

build.fsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,12 @@ Target.createFinal "StopServer" (fun _ ->
105105
//Process.killAllByName "dotnet"
106106
)
107107

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

110115
// --------------------------------------------------------------------------------------
111116
// Run the unit tests using test runner

global.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"sdk": {
3-
"version": "10.0.200",
3+
"version": "10.0.102",
44
"rollForward": "latestMinor"
55
}
66
}

src/SwaggerProvider.DesignTime/Utils.fs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,9 +332,13 @@ module SchemaReader =
332332
resolvedPath
333333
}
334334

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

338+
do
339+
for name in (defaultArg occupiedNames Seq.empty) do
340+
hash.Add(name.ToLowerInvariant()) |> ignore
341+
338342
let rec findUniq prefix i =
339343
let newName = sprintf "%s%s" prefix (if i = 0 then "" else i.ToString())
340344
let key = newName.ToLowerInvariant()

src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
9696
let (|NoMediaType|_|)(content: IDictionary<string, OpenApiMediaType>) =
9797
if isNull content || content.Count = 0 then Some() else None
9898

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

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

150-
let orderedParameters =
151-
let required, optional =
152-
[ yield! openApiParameters
153-
if bodyFormatAndParam.IsSome then
154-
yield bodyFormatAndParam.Value |> snd ]
155-
|> List.distinctBy(fun op -> op.Name, op.In)
156-
|> List.partition(_.Required)
150+
let requiredOpenApiParams, optionalOpenApiParams =
151+
[ yield! openApiParameters
152+
if bodyFormatAndParam.IsSome then
153+
yield bodyFormatAndParam.Value |> snd ]
154+
|> List.distinctBy(fun op -> op.Name, op.In)
155+
|> List.partition(_.Required)
157156

158-
List.append required optional
159-
160-
let providedParameters =
161-
((Set.empty, []), orderedParameters)
157+
let buildProvidedParameters usedNames (paramList: IOpenApiParameter list) =
158+
((usedNames, []), paramList)
162159
||> List.fold(fun (names, parameters) current ->
163160
let names, paramName = uniqueParamName names current
164161

@@ -173,12 +170,32 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
173170
ProvidedParameter(paramName, paramType, false, paramDefaultValue)
174171

175172
(names, providedParam :: parameters))
176-
|> snd
177-
// because we built up our list in reverse order with the fold,
178-
// reverse it again so that all required properties come first
179-
|> List.rev
173+
|> fun (finalNames, ps) -> finalNames, List.rev ps
174+
175+
let namesAfterRequired, requiredProvidedParams =
176+
buildProvidedParameters Set.empty requiredOpenApiParams
177+
178+
let _, optionalProvidedParams =
179+
buildProvidedParameters namesAfterRequired optionalOpenApiParams
180+
181+
let ctArgIndex, parameters =
182+
let scope = UniqueNameGenerator()
183+
184+
(requiredProvidedParams @ optionalProvidedParams)
185+
|> List.iter(fun p -> scope.MakeUnique p.Name |> ignore)
186+
187+
let ctName = scope.MakeUnique "cancellationToken"
180188

181-
payloadTy.ToMediaType(), providedParameters
189+
let ctParam =
190+
ProvidedParameter(ctName, typeof<Threading.CancellationToken>, false, null)
191+
// CT is appended last to preserve existing positional argument calls
192+
let ctArgIndex =
193+
List.length requiredProvidedParams
194+
+ List.length optionalProvidedParams
195+
196+
ctArgIndex, requiredProvidedParams @ optionalProvidedParams @ [ ctParam ]
197+
198+
payloadTy.ToMediaType(), parameters, ctArgIndex
182199

183200
// find the inner type value
184201
let retMimeAndTy =
@@ -264,8 +281,20 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
264281
// Locates parameters matching the arguments
265282
let mutable payloadExp = None
266283

284+
// CT is inserted at ctArgIndex. Extract it by position.
285+
let apiArgs, ct =
286+
let allArgs = List.tail args // skip `this`
287+
let ctArg = List.item ctArgIndex allArgs
288+
289+
let apiArgs =
290+
allArgs
291+
|> List.indexed
292+
|> List.choose(fun (i, a) -> if i = ctArgIndex then None else Some a)
293+
294+
apiArgs, Expr.Cast<Threading.CancellationToken>(ctArg)
295+
267296
let parameters =
268-
List.tail args // skip `this` param
297+
apiArgs
269298
|> List.choose (function
270299
| ShapeVar sVar as expr ->
271300
let param =
@@ -392,39 +421,42 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
392421
@>
393422

394423
let action =
395-
<@ (%this).CallAsync(%httpRequestMessageWithPayload, errorCodes, errorDescriptions) @>
424+
<@ (%this).CallAsync(%httpRequestMessageWithPayload, errorCodes, errorDescriptions, %ct) @>
396425

397426
let responseObj =
398427
let innerReturnType = defaultArg retTy null
399428

400429
<@
401430
let x = %action
431+
let ct = %ct
402432

403433
task {
404434
let! response = x
405-
let! content = response.ReadAsStringAsync()
435+
let! content = RuntimeHelpers.readContentAsString response ct
406436
return (%this).Deserialize(content, innerReturnType)
407437
}
408438
@>
409439

410440
let responseStream =
411441
<@
412442
let x = %action
443+
let ct = %ct
413444

414445
task {
415446
let! response = x
416-
let! data = response.ReadAsStreamAsync()
447+
let! data = RuntimeHelpers.readContentAsStream response ct
417448
return data
418449
}
419450
@>
420451

421452
let responseString =
422453
<@
423454
let x = %action
455+
let ct = %ct
424456

425457
task {
426458
let! response = x
427-
let! data = response.ReadAsStringAsync()
459+
let! data = RuntimeHelpers.readContentAsString response ct
428460
return data
429461
}
430462
@>
@@ -599,5 +631,6 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler,
599631
clientName.Length + 1
600632

601633
let name = OperationCompiler.GetMethodNameCandidate op skipLength ignoreOperationId
602-
compileOperation (methodNameScope.MakeUnique name) op)
634+
let uniqueName = methodNameScope.MakeUnique name
635+
compileOperation uniqueName op)
603636
|> ty.AddMembers)

src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,11 @@ type ProvidedApiClientBase(httpClient: HttpClient, options: JsonSerializerOption
4444
default _.Deserialize(value, retTy: Type) : obj =
4545
JsonSerializer.Deserialize(value, retTy, options)
4646

47-
member this.CallAsync(request: HttpRequestMessage, errorCodes: string[], errorDescriptions: string[]) : Task<HttpContent> =
47+
member this.CallAsync
48+
(request: HttpRequestMessage, errorCodes: string[], errorDescriptions: string[], cancellationToken: System.Threading.CancellationToken)
49+
: Task<HttpContent> =
4850
task {
49-
let! response = this.HttpClient.SendAsync(request)
51+
let! response = this.HttpClient.SendAsync(request, cancellationToken)
5052

5153
if response.IsSuccessStatusCode then
5254
return response.Content
@@ -61,7 +63,11 @@ type ProvidedApiClientBase(httpClient: HttpClient, options: JsonSerializerOption
6163
let! body =
6264
task {
6365
try
66+
#if NET5_0_OR_GREATER
67+
return! response.Content.ReadAsStringAsync(cancellationToken)
68+
#else
6469
return! response.Content.ReadAsStringAsync()
70+
#endif
6571
with _ ->
6672
// If reading the body fails (e.g., disposed stream or invalid charset),
6773
// fall back to an empty body so we can still throw OpenApiException.

src/SwaggerProvider.Runtime/RuntimeHelpers.fs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,20 @@ module RuntimeHelpers =
273273

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

276+
let readContentAsString (content: HttpContent) (ct: System.Threading.CancellationToken) : Task<string> =
277+
#if NET5_0_OR_GREATER
278+
content.ReadAsStringAsync(ct)
279+
#else
280+
content.ReadAsStringAsync()
281+
#endif
282+
283+
let readContentAsStream (content: HttpContent) (ct: System.Threading.CancellationToken) : Task<IO.Stream> =
284+
#if NET5_0_OR_GREATER
285+
content.ReadAsStreamAsync(ct)
286+
#else
287+
content.ReadAsStreamAsync()
288+
#endif
289+
276290
let taskCast runtimeTy (task: Task<obj>) =
277291
let castFn = typeof<TaskExtensions>.GetMethod "cast"
278292

tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
<Compile Include="v3\Swagger.NullableDate.Tests.fs" />
3131
<Compile Include="v3\Swagger.SchemaReaderErrors.Tests.fs" />
3232
<Compile Include="v3\Swashbuckle.ReturnControllers.Tests.fs" />
33+
<Compile Include="v3\Swashbuckle.CancellationToken.Tests.fs" />
3334
<Compile Include="v3\Swashbuckle.ReturnTextControllers.Tests.fs" />
3435
<Compile Include="v3\Swashbuckle.UpdateControllers.Tests.fs" />
3536
<Compile Include="v3\Swashbuckle.ResourceControllers.Tests.fs" />
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
module Swashbuckle.v3.CancellationTokenTests
2+
3+
open Xunit
4+
open FsUnitTyped
5+
open System
6+
open System.Net.Http
7+
open System.Threading
8+
open SwaggerProvider
9+
open Swashbuckle.v3.ReturnControllersTests
10+
11+
type WebAPIAsync =
12+
OpenApiClientProvider<"http://localhost:5000/swagger/v1/openapi.json", IgnoreOperationId=true, SsrfProtection=false, PreferAsync=true>
13+
14+
let apiAsync =
15+
let handler = new HttpClientHandler(UseCookies = false)
16+
17+
let client =
18+
new HttpClient(handler, true, BaseAddress = Uri("http://localhost:5000"))
19+
20+
WebAPIAsync.Client(client)
21+
22+
[<Fact>]
23+
let ``Call generated method without CancellationToken uses default token``() =
24+
task {
25+
let! result = api.GetApiReturnBoolean()
26+
result |> shouldEqual true
27+
}
28+
29+
[<Fact>]
30+
let ``Call generated method with explicit CancellationToken None``() =
31+
task {
32+
let! result = api.GetApiReturnBoolean(CancellationToken.None)
33+
result |> shouldEqual true
34+
}
35+
36+
[<Fact>]
37+
let ``Call generated method with valid CancellationTokenSource token``() =
38+
task {
39+
use cts = new CancellationTokenSource()
40+
let! result = api.GetApiReturnInt32(cts.Token)
41+
result |> shouldEqual 42
42+
}
43+
44+
[<Fact>]
45+
let ``Call generated method with already-cancelled token raises OperationCanceledException``() =
46+
task {
47+
use cts = new CancellationTokenSource()
48+
cts.Cancel()
49+
50+
try
51+
let! _ = api.GetApiReturnString(cts.Token)
52+
failwith "Expected OperationCanceledException"
53+
with
54+
| :? OperationCanceledException -> ()
55+
| :? System.AggregateException as aex when (aex.InnerException :? OperationCanceledException) -> ()
56+
}
57+
58+
[<Fact>]
59+
let ``Call POST generated method with explicit CancellationToken None``() =
60+
task {
61+
let! result = api.PostApiReturnString(CancellationToken.None)
62+
result |> shouldEqual "Hello world"
63+
}
64+
65+
[<Fact>]
66+
let ``Call async generated method without CancellationToken uses default token``() =
67+
async {
68+
let! result = apiAsync.GetApiReturnBoolean()
69+
result |> shouldEqual true
70+
}
71+
|> Async.StartAsTask
72+
73+
[<Fact>]
74+
let ``Call method with required param and explicit CancellationToken``() =
75+
task {
76+
use cts = new CancellationTokenSource()
77+
let! result = api.GetApiUpdateString("Serge", cts.Token)
78+
result |> shouldEqual "Hello, Serge"
79+
}
80+
81+
[<Fact>]
82+
let ``Call method with optional param and explicit CancellationToken``() =
83+
task {
84+
use cts = new CancellationTokenSource()
85+
let! result = api.GetApiUpdateBool(Some true, cts.Token)
86+
result |> shouldEqual false
87+
}
88+
89+
[<Fact>]
90+
let ``Call async generated method with explicit CancellationToken``() =
91+
async {
92+
use cts = new CancellationTokenSource()
93+
let! result = apiAsync.GetApiReturnInt32(cts.Token)
94+
result |> shouldEqual 42
95+
}
96+
|> Async.StartAsTask
97+
98+
[<Fact>]
99+
let ``Call stream-returning method with explicit CancellationToken``() =
100+
task {
101+
use cts = new CancellationTokenSource()
102+
let! result = api.GetApiReturnFile(cts.Token)
103+
use reader = new IO.StreamReader(result)
104+
let! content = reader.ReadToEndAsync()
105+
content |> shouldEqual "I am totally a file's\ncontent"
106+
}
107+
108+
[<Fact>]
109+
let ``Call text-returning method with explicit CancellationToken``() =
110+
task {
111+
use cts = new CancellationTokenSource()
112+
let! result = api.GetApiReturnPlain(cts.Token)
113+
result |> shouldEqual "Hello world"
114+
}
115+
116+
[<Fact>]
117+
let ``Call async generated method with already-cancelled token raises OperationCanceledException``() =
118+
async {
119+
use cts = new CancellationTokenSource()
120+
cts.Cancel()
121+
122+
try
123+
let! _ = apiAsync.GetApiReturnString(cts.Token)
124+
failwith "Expected OperationCanceledException"
125+
with
126+
| :? OperationCanceledException -> ()
127+
| :? AggregateException as aex when (aex.InnerException :? OperationCanceledException) -> ()
128+
}
129+
|> Async.StartAsTask
130+
131+
[<Fact>]
132+
let ``Call async POST generated method with explicit CancellationToken``() =
133+
async {
134+
use cts = new CancellationTokenSource()
135+
let! result = apiAsync.PostApiReturnString(cts.Token)
136+
result |> shouldEqual "Hello world"
137+
}
138+
|> Async.StartAsTask

0 commit comments

Comments
 (0)