From 859cd7361c3ac6fec0c38227489faf20ba8e2290 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 16:42:38 +0000 Subject: [PATCH 1/4] 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> --- .../v3/OperationCompiler.fs | 59 ++++++++++++------- .../ProvidedApiClientBase.fs | 7 ++- .../RuntimeHelpersTests.fs | 34 ++++++++++- 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs index 2eb5fcc..8a307fb 100644 --- a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs @@ -178,7 +178,10 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, // reverse it again so that all required properties come first |> List.rev - payloadTy.ToMediaType(), providedParameters + let ctParam = + ProvidedParameter("cancellationToken", typeof, optionalValue = box Threading.CancellationToken.None) + + payloadTy.ToMediaType(), providedParameters @ [ ctParam ] // find the inner type value let retMimeAndTy = @@ -263,32 +266,46 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, // Locates parameters matching the arguments let mutable payloadExp = None + let mutable ctExpr: Expr option = None let parameters = List.tail args // skip `this` param |> List.choose (function | ShapeVar sVar as expr -> - let param = - openApiParameters - |> Seq.tryFind(fun x -> - // pain point: we have to make sure that the set of names we search for here are the same as the set of names generated when we make `parameters` above - let baseName = niceCamelName x.Name - baseName = sVar.Name || (unambiguousName x) = sVar.Name) - - match param with - | Some(par) -> Some(par, expr) - | _ -> - let payloadType = PayloadType.Parse sVar.Name - - match payloadExp with - | None -> - payloadExp <- Some(payloadType, Expr.Coerce(expr, typeof)) - None - | Some _ -> - failwithf - $"More than one payload parameter is specified: '%A{payloadType}' & '%A{payloadExp.Value |> fst}'" + // cancellationToken is added by the compiler, not from OpenAPI spec + if sVar.Name = "cancellationToken" then + ctExpr <- + Some( + Expr.Coerce(expr, typeof) + |> Expr.Cast + ) + + None + else + + let param = + openApiParameters + |> Seq.tryFind(fun x -> + // pain point: we have to make sure that the set of names we search for here are the same as the set of names generated when we make `parameters` above + let baseName = niceCamelName x.Name + baseName = sVar.Name || (unambiguousName x) = sVar.Name) + + match param with + | Some(par) -> Some(par, expr) + | _ -> + let payloadType = PayloadType.Parse sVar.Name + + match payloadExp with + | None -> + payloadExp <- Some(payloadType, Expr.Coerce(expr, typeof)) + None + | Some _ -> + failwithf + $"More than one payload parameter is specified: '%A{payloadType}' & '%A{payloadExp.Value |> fst}'" | _ -> failwithf $"Function '%s{providedMethodName}' does not support functions as arguments.") + let ct = ctExpr |> Option.defaultValue <@ Threading.CancellationToken.None @> + // Makes argument a string // TODO: Make body an exception let coerceString exp = let obj = Expr.Coerce(exp, typeof) |> Expr.Cast @@ -392,7 +409,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 diff --git a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs index 44f12cf..0dd4511 100644 --- a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs +++ b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs @@ -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 = + this.CallAsync(request, errorCodes, errorDescriptions, System.Threading.CancellationToken.None) + + member this.CallAsync + (request: HttpRequestMessage, errorCodes: string[], errorDescriptions: string[], cancellationToken: System.Threading.CancellationToken) + : Task = task { - let! response = this.HttpClient.SendAsync(request) + let! response = this.HttpClient.SendAsync(request, cancellationToken) if response.IsSuccessStatusCode then return response.Content diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 1933a32..f534bd2 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -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) @@ -495,3 +496,34 @@ module OpenApiExceptionTests = () } + + [] + 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" + } + + [] + 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(fun () -> + task { + let! _ = client.CallAsync(request, [||], [||], cts.Token) + () + }) + + () + } From 27ff627a82273550f46c01d2be64d49eb7406412 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 16:47:55 +0000 Subject: [PATCH 2/4] ci: trigger checks From bd221351a0936c8a88d89d6efeda45f4a7f11ed8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:02:32 +0100 Subject: [PATCH 3/4] 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> --- global.json | 2 +- .../v3/OperationCompiler.fs | 24 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/global.json b/global.json index ee681bf..bb62055 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.200", + "version": "10.0.102", "rollForward": "latestMinor" } } diff --git a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs index 8a307fb..4167287 100644 --- a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs @@ -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] @@ -178,10 +178,16 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, // reverse it again so that all required properties come first |> List.rev - let ctParam = - ProvidedParameter("cancellationToken", typeof, optionalValue = box Threading.CancellationToken.None) + let parameters = + if includeCancellationToken then + let ctParam = + ProvidedParameter("cancellationToken", typeof) - payloadTy.ToMediaType(), providedParameters @ [ ctParam ] + providedParameters @ [ ctParam ] + else + providedParameters + + payloadTy.ToMediaType(), parameters // find the inner type value let retMimeAndTy = @@ -608,7 +614,7 @@ 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 @@ -616,5 +622,11 @@ 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 + // 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) From b2f0d8088db36599e9182266ec8cfca29511da79 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:01:50 +0100 Subject: [PATCH 4/4] Add type provider integration tests for CancellationToken-overloaded methods (#338) --- .../v3/OperationCompiler.fs | 67 +++++++++---------- .../SwaggerProvider.ProviderTests.fsproj | 1 + .../v3/Swashbuckle.CancellationToken.Tests.fs | 43 ++++++++++++ 3 files changed, 77 insertions(+), 34 deletions(-) create mode 100644 tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs diff --git a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs index 4167287..33b8705 100644 --- a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs @@ -272,46 +272,45 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, // Locates parameters matching the arguments let mutable payloadExp = None - let mutable ctExpr: Expr option = None + + // When the CancellationToken overload is generated, CancellationToken is always appended last. + // Extract it by position to avoid name-collision issues and invalid Expr.Coerce + // on a struct type (which generates an invalid castclass IL instruction). + let apiArgs, ct = + let allArgs = List.tail args // skip `this` + + if includeCancellationToken then + match List.rev allArgs with + | ctArg :: revApiArgs -> List.rev revApiArgs, Expr.Cast(ctArg) + | [] -> failwith "Expected CancellationToken argument but argument list was empty" + else + allArgs, <@ Threading.CancellationToken.None @> let parameters = - List.tail args // skip `this` param + apiArgs |> List.choose (function | ShapeVar sVar as expr -> - // cancellationToken is added by the compiler, not from OpenAPI spec - if sVar.Name = "cancellationToken" then - ctExpr <- - Some( - Expr.Coerce(expr, typeof) - |> Expr.Cast - ) - - None - else - - let param = - openApiParameters - |> Seq.tryFind(fun x -> - // pain point: we have to make sure that the set of names we search for here are the same as the set of names generated when we make `parameters` above - let baseName = niceCamelName x.Name - baseName = sVar.Name || (unambiguousName x) = sVar.Name) - - match param with - | Some(par) -> Some(par, expr) - | _ -> - let payloadType = PayloadType.Parse sVar.Name - - match payloadExp with - | None -> - payloadExp <- Some(payloadType, Expr.Coerce(expr, typeof)) - None - | Some _ -> - failwithf - $"More than one payload parameter is specified: '%A{payloadType}' & '%A{payloadExp.Value |> fst}'" + let param = + openApiParameters + |> Seq.tryFind(fun x -> + // pain point: we have to make sure that the set of names we search for here are the same as the set of names generated when we make `parameters` above + let baseName = niceCamelName x.Name + baseName = sVar.Name || (unambiguousName x) = sVar.Name) + + match param with + | Some(par) -> Some(par, expr) + | _ -> + let payloadType = PayloadType.Parse sVar.Name + + match payloadExp with + | None -> + payloadExp <- Some(payloadType, Expr.Coerce(expr, typeof)) + None + | Some _ -> + failwithf + $"More than one payload parameter is specified: '%A{payloadType}' & '%A{payloadExp.Value |> fst}'" | _ -> failwithf $"Function '%s{providedMethodName}' does not support functions as arguments.") - let ct = ctExpr |> Option.defaultValue <@ Threading.CancellationToken.None @> - // Makes argument a string // TODO: Make body an exception let coerceString exp = let obj = Expr.Coerce(exp, typeof) |> Expr.Cast diff --git a/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj b/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj index ecb8b27..14c5674 100644 --- a/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj +++ b/tests/SwaggerProvider.ProviderTests/SwaggerProvider.ProviderTests.fsproj @@ -30,6 +30,7 @@ + diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs new file mode 100644 index 0000000..d54ec99 --- /dev/null +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs @@ -0,0 +1,43 @@ +module Swashbuckle.v3.CancellationTokenTests + +open Xunit +open FsUnitTyped +open System +open System.Threading +open Swashbuckle.v3.ReturnControllersTests + +[] +let ``Call generated method with explicit CancellationToken None``() = + task { + let! result = api.GetApiReturnBoolean(CancellationToken.None) + result |> shouldEqual true + } + +[] +let ``Call generated method with valid CancellationTokenSource token``() = + task { + use cts = new CancellationTokenSource() + let! result = api.GetApiReturnInt32(cts.Token) + result |> shouldEqual 42 + } + +[] +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) -> () + } + +[] +let ``Call POST generated method with explicit CancellationToken None``() = + task { + let! result = api.PostApiReturnString(CancellationToken.None) + result |> shouldEqual "Hello world" + }