From 859cd7361c3ac6fec0c38227489faf20ba8e2290 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 16:42:38 +0000 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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" + } From ee4db302d7359c65f0e16d8dcb21a4b9f628d168 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:06:47 +0100 Subject: [PATCH 05/13] 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> --- .../v3/OperationCompiler.fs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs index 33b8705..25d03ff 100644 --- a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs @@ -157,7 +157,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, List.append required optional - let providedParameters = + let usedNames, providedParameters = ((Set.empty, []), orderedParameters) ||> List.fold(fun (names, parameters) current -> let names, paramName = uniqueParamName names current @@ -173,16 +173,21 @@ 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 (names, ps) -> names, List.rev ps let parameters = if includeCancellationToken then - let ctParam = - ProvidedParameter("cancellationToken", typeof) + // Find a unique name for the CancellationToken parameter that doesn't + // conflict with any OpenAPI parameter already in use. + let ctParamName = + Seq.initInfinite(fun i -> + if i = 0 then + "cancellationToken" + else + $"cancellationToken{i}") + |> Seq.find(fun n -> not(Set.contains n usedNames)) + let ctParam = ProvidedParameter(ctParamName, typeof) providedParameters @ [ ctParam ] else providedParameters From c56be4366f953fdceeb56a6b7d0e42a35f1a2edd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:46:22 +0100 Subject: [PATCH 06/13] 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> --- build.fsx | 7 +- .../v3/OperationCompiler.fs | 79 +++++++++++-------- 2 files changed, 52 insertions(+), 34 deletions(-) diff --git a/build.fsx b/build.fsx index f66eec0..54507cf 100644 --- a/build.fsx +++ b/build.fsx @@ -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 diff --git a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs index 25d03ff..4107dda 100644 --- a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs @@ -96,7 +96,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let (|NoMediaType|_|)(content: IDictionary) = 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) = @@ -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 usedNames, providedParameters = - ((Set.empty, []), orderedParameters) + let buildProvidedParameters usedNames (paramList: IOpenApiParameter list) = + ((usedNames, []), paramList) ||> List.fold(fun (names, parameters) current -> let names, paramName = uniqueParamName names current @@ -173,26 +170,37 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, ProvidedParameter(paramName, paramType, false, paramDefaultValue) (names, providedParam :: parameters)) - |> fun (names, ps) -> names, List.rev ps + |> fun (finalNames, ps) -> finalNames, List.rev ps + + let namesAfterRequired, requiredProvidedParams = + buildProvidedParameters Set.empty requiredOpenApiParams - let parameters = + let _, optionalProvidedParams = + buildProvidedParameters namesAfterRequired optionalOpenApiParams + + let ctArgIndex, parameters = if includeCancellationToken then - // Find a unique name for the CancellationToken parameter that doesn't - // conflict with any OpenAPI parameter already in use. - let ctParamName = - Seq.initInfinite(fun i -> - if i = 0 then - "cancellationToken" - else - $"cancellationToken{i}") - |> Seq.find(fun n -> not(Set.contains n usedNames)) + // Collect all used param names to generate a unique CT parameter name + let usedNames = + (requiredProvidedParams @ optionalProvidedParams) + |> List.map(fun p -> p.Name) + |> Set.ofList + + let rec findUniqueName candidate n = + if Set.contains candidate usedNames then + findUniqueName $"cancellationToken{n}" (n + 1) + else + candidate - let ctParam = ProvidedParameter(ctParamName, typeof) - providedParameters @ [ ctParam ] + let ctName = findUniqueName "cancellationToken" 1 + let ctParam = ProvidedParameter(ctName, typeof) + // CT is inserted after required params so it never follows optional params + let ctArgIndex = List.length requiredProvidedParams + ctArgIndex, requiredProvidedParams @ [ ctParam ] @ optionalProvidedParams else - providedParameters + -1, requiredProvidedParams @ optionalProvidedParams - payloadTy.ToMediaType(), parameters + payloadTy.ToMediaType(), parameters, ctArgIndex // find the inner type value let retMimeAndTy = @@ -278,16 +286,21 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, // Locates parameters matching the arguments let mutable payloadExp = 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). + // 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 - match List.rev allArgs with - | ctArg :: revApiArgs -> List.rev revApiArgs, Expr.Cast(ctArg) - | [] -> failwith "Expected CancellationToken argument but argument list was empty" + 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(ctArg) else allArgs, <@ Threading.CancellationToken.None @> From e77c843cbaeb0280c89c083455f80661bf864bc6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:05:45 +0000 Subject: [PATCH 07/13] 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> --- .../v3/OperationCompiler.fs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs index 4107dda..636a482 100644 --- a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs @@ -180,19 +180,12 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let ctArgIndex, parameters = if includeCancellationToken then - // Collect all used param names to generate a unique CT parameter name - let usedNames = - (requiredProvidedParams @ optionalProvidedParams) - |> List.map(fun p -> p.Name) - |> Set.ofList - - let rec findUniqueName candidate n = - if Set.contains candidate usedNames then - findUniqueName $"cancellationToken{n}" (n + 1) - else - candidate + let scope = UniqueNameGenerator() + + (requiredProvidedParams @ optionalProvidedParams) + |> List.iter(fun p -> scope.MakeUnique p.Name |> ignore) - let ctName = findUniqueName "cancellationToken" 1 + let ctName = scope.MakeUnique "cancellationToken" let ctParam = ProvidedParameter(ctName, typeof) // CT is inserted after required params so it never follows optional params let ctArgIndex = List.length requiredProvidedParams From e1c8879994b7c1ce915337e832d015d36c1f44e1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:23:43 +0000 Subject: [PATCH 08/13] 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> --- src/SwaggerProvider.DesignTime/Utils.fs | 6 +++++- tests/SwaggerProvider.Tests/UtilsTests.fs | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/SwaggerProvider.DesignTime/Utils.fs b/src/SwaggerProvider.DesignTime/Utils.fs index 298b672..a5978bd 100644 --- a/src/SwaggerProvider.DesignTime/Utils.fs +++ b/src/SwaggerProvider.DesignTime/Utils.fs @@ -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() diff --git a/tests/SwaggerProvider.Tests/UtilsTests.fs b/tests/SwaggerProvider.Tests/UtilsTests.fs index d4149cf..59bd9eb 100644 --- a/tests/SwaggerProvider.Tests/UtilsTests.fs +++ b/tests/SwaggerProvider.Tests/UtilsTests.fs @@ -71,3 +71,25 @@ module UniqueNameGeneratorTests = let gen = UniqueNameGenerator() gen.MakeUnique "" |> shouldEqual "" gen.MakeUnique "" |> shouldEqual "1" + + [] + let ``occupied names seed prevents first-use from returning the reserved name unchanged``() = + let gen = UniqueNameGenerator(occupiedNames = [ "Foo" ]) + gen.MakeUnique "Foo" |> shouldEqual "Foo1" + + [] + let ``occupied names seed is case-insensitive``() = + let gen = UniqueNameGenerator(occupiedNames = [ "foo" ]) + gen.MakeUnique "Foo" |> shouldEqual "Foo1" + + [] + let ``multiple occupied names are all reserved``() = + let gen = UniqueNameGenerator(occupiedNames = [ "Alpha"; "Beta" ]) + gen.MakeUnique "Alpha" |> shouldEqual "Alpha1" + gen.MakeUnique "Beta" |> shouldEqual "Beta1" + gen.MakeUnique "Gamma" |> shouldEqual "Gamma" + + [] + let ``empty occupied names sequence behaves like default constructor``() = + let gen = UniqueNameGenerator(occupiedNames = []) + gen.MakeUnique "Foo" |> shouldEqual "Foo" From 3824c0065a06b485da121110d8a73fb05e8e1e9c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:05:36 +0000 Subject: [PATCH 09/13] 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> --- .../v3/OperationCompiler.fs | 53 ++++++++----------- .../ProvidedApiClientBase.fs | 3 -- .../RuntimeHelpersTests.fs | 6 +-- 3 files changed, 25 insertions(+), 37 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs index 636a482..78af9e1 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) (includeCancellationToken: bool) = + let compileOperation (providedMethodName: string) (apiCall: ApiCall) = let path, pathItem, opTy = apiCall let operation = pathItem.Operations[opTy] @@ -179,19 +179,21 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, buildProvidedParameters namesAfterRequired optionalOpenApiParams let ctArgIndex, parameters = - if includeCancellationToken then - let scope = UniqueNameGenerator() + let scope = UniqueNameGenerator() - (requiredProvidedParams @ optionalProvidedParams) - |> List.iter(fun p -> scope.MakeUnique p.Name |> ignore) + (requiredProvidedParams @ optionalProvidedParams) + |> List.iter(fun p -> scope.MakeUnique p.Name |> ignore) - let ctName = scope.MakeUnique "cancellationToken" - let ctParam = ProvidedParameter(ctName, typeof) - // 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 + let ctName = scope.MakeUnique "cancellationToken" + // null default value is interpreted as default(CancellationToken) == CancellationToken.None + let ctParam = + ProvidedParameter(ctName, typeof, false, null) + // CT is appended last so it comes after all optional params + let ctArgIndex = + List.length requiredProvidedParams + + List.length optionalProvidedParams + + ctArgIndex, requiredProvidedParams @ optionalProvidedParams @ [ ctParam ] payloadTy.ToMediaType(), parameters, ctArgIndex @@ -279,23 +281,17 @@ 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. + // CT is always the last argument (at ctArgIndex). Extract it by position. let apiArgs, ct = let allArgs = List.tail args // skip `this` + let ctArg = List.item ctArgIndex allArgs - 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) - let apiArgs = - allArgs - |> List.indexed - |> List.choose(fun (i, a) -> if i = ctArgIndex then None else Some a) - - apiArgs, Expr.Cast(ctArg) - else - allArgs, <@ Threading.CancellationToken.None @> + apiArgs, Expr.Cast(ctArg) let parameters = apiArgs @@ -633,10 +629,5 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let name = OperationCompiler.GetMethodNameCandidate op skipLength ignoreOperationId 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 ]) + [ compileOperation uniqueName op ]) |> ty.AddMembers) diff --git a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs index 0dd4511..1cf2e4f 100644 --- a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs +++ b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs @@ -44,9 +44,6 @@ 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 = - this.CallAsync(request, errorCodes, errorDescriptions, System.Threading.CancellationToken.None) - member this.CallAsync (request: HttpRequestMessage, errorCodes: string[], errorDescriptions: string[], cancellationToken: System.Threading.CancellationToken) : Task = diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index f534bd2..ca0b19a 100644 --- a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs +++ b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs @@ -447,7 +447,7 @@ module OpenApiExceptionTests = let! ex = Assert.ThrowsAsync(fun () -> task { - let! _ = client.CallAsync(request, [| "404" |], [| "Pet not found" |]) + let! _ = client.CallAsync(request, [| "404" |], [| "Pet not found" |], CancellationToken.None) () }) @@ -468,7 +468,7 @@ module OpenApiExceptionTests = let! ex = Assert.ThrowsAsync(fun () -> task { - let! _ = client.CallAsync(request, [| "404" |], [| "Pet not found" |]) + let! _ = client.CallAsync(request, [| "404" |], [| "Pet not found" |], CancellationToken.None) () }) @@ -490,7 +490,7 @@ module OpenApiExceptionTests = let! _ = Assert.ThrowsAsync(fun () -> task { - let! _ = client.CallAsync(request, [| "404" |], [| "Pet not found" |]) + let! _ = client.CallAsync(request, [| "404" |], [| "Pet not found" |], CancellationToken.None) () }) From e06b5b0d8737865713b99c481b2471546914e808 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Wed, 25 Mar 2026 17:29:38 +0100 Subject: [PATCH 10/13] 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 --- .../v3/OperationCompiler.fs | 10 +++---- .../v3/Swashbuckle.CancellationToken.Tests.fs | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs index 78af9e1..f7cea17 100644 --- a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs @@ -185,10 +185,10 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, |> List.iter(fun p -> scope.MakeUnique p.Name |> ignore) let ctName = scope.MakeUnique "cancellationToken" - // null default value is interpreted as default(CancellationToken) == CancellationToken.None + let ctParam = ProvidedParameter(ctName, typeof, false, null) - // CT is appended last so it comes after all optional params + // CT is appended last to preserve existing positional argument calls let ctArgIndex = List.length requiredProvidedParams + List.length optionalProvidedParams @@ -281,7 +281,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, // Locates parameters matching the arguments let mutable payloadExp = None - // CT is always the last argument (at ctArgIndex). Extract it by position. + // 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 @@ -620,7 +620,7 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let methodNameScope = UniqueNameGenerator() operations - |> List.collect(fun op -> + |> List.map(fun op -> let skipLength = if String.IsNullOrEmpty clientName then 0 @@ -629,5 +629,5 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let name = OperationCompiler.GetMethodNameCandidate op skipLength ignoreOperationId let uniqueName = methodNameScope.MakeUnique name - [ compileOperation uniqueName op ]) + compileOperation uniqueName op) |> ty.AddMembers) diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs index d54ec99..1c38ee5 100644 --- a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs @@ -3,9 +3,29 @@ 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) + +[] +let ``Call generated method without CancellationToken uses default token``() = + task { + let! result = api.GetApiReturnBoolean() + result |> shouldEqual true + } + [] let ``Call generated method with explicit CancellationToken None``() = task { @@ -41,3 +61,10 @@ let ``Call POST generated method with explicit CancellationToken None``() = let! result = api.PostApiReturnString(CancellationToken.None) result |> shouldEqual "Hello world" } + +[] +let ``Call async generated method without CancellationToken uses default token``() = + async { + let! result = apiAsync.GetApiReturnBoolean() + result |> shouldEqual true + } From 16053ea7ab3df3bd3db6a3a7b72b03b7d4af283f Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Wed, 25 Mar 2026 20:26:12 +0100 Subject: [PATCH 11/13] 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. --- AGENTS.md | 4 ++++ .../v3/OperationCompiler.fs | 9 ++++--- .../ProvidedApiClientBase.fs | 4 ++++ src/SwaggerProvider.Runtime/RuntimeHelpers.fs | 14 +++++++++++ .../v3/Swashbuckle.CancellationToken.Tests.fs | 24 +++++++++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1b7b8fa..5481204 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs index f7cea17..1782213 100644 --- a/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs +++ b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs @@ -428,10 +428,11 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, <@ 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) } @> @@ -439,10 +440,11 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let responseStream = <@ let x = %action + let ct = %ct task { let! response = x - let! data = response.ReadAsStreamAsync() + let! data = RuntimeHelpers.readContentAsStream response ct return data } @> @@ -450,10 +452,11 @@ type OperationCompiler(schema: OpenApiDocument, defCompiler: DefinitionCompiler, let responseString = <@ let x = %action + let ct = %ct task { let! response = x - let! data = response.ReadAsStringAsync() + let! data = RuntimeHelpers.readContentAsString response ct return data } @> diff --git a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs index 1cf2e4f..62e8da3 100644 --- a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs +++ b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs @@ -63,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. diff --git a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs index fabb0ec..a03b150 100644 --- a/src/SwaggerProvider.Runtime/RuntimeHelpers.fs +++ b/src/SwaggerProvider.Runtime/RuntimeHelpers.fs @@ -273,6 +273,20 @@ module RuntimeHelpers = castFn.MakeGenericMethod([| runtimeTy |]).Invoke(null, [| asyncOp |]) + let readContentAsString (content: HttpContent) (ct: System.Threading.CancellationToken) : Task = +#if NET5_0_OR_GREATER + content.ReadAsStringAsync(ct) +#else + content.ReadAsStringAsync() +#endif + + let readContentAsStream (content: HttpContent) (ct: System.Threading.CancellationToken) : Task = +#if NET5_0_OR_GREATER + content.ReadAsStreamAsync(ct) +#else + content.ReadAsStreamAsync() +#endif + let taskCast runtimeTy (task: Task) = let castFn = typeof.GetMethod "cast" diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs index 1c38ee5..f859ee2 100644 --- a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs @@ -68,3 +68,27 @@ let ``Call async generated method without CancellationToken uses default token`` let! result = apiAsync.GetApiReturnBoolean() result |> shouldEqual true } + +[] +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" + } + +[] +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 + } + +[] +let ``Call async generated method with explicit CancellationToken``() = + async { + use cts = new CancellationTokenSource() + let! result = apiAsync.GetApiReturnInt32(cts.Token) + result |> shouldEqual 42 + } From 74d62a78e9ffc407da4bc49ab7bcbf2d1cdaf2ca Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Wed, 25 Mar 2026 20:35:08 +0100 Subject: [PATCH 12/13] test: add CT coverage for stream, text/plain, async cancellation, and async POST paths --- .../v3/Swashbuckle.CancellationToken.Tests.fs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs index f859ee2..0b6f89a 100644 --- a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs @@ -92,3 +92,43 @@ let ``Call async generated method with explicit CancellationToken``() = let! result = apiAsync.GetApiReturnInt32(cts.Token) result |> shouldEqual 42 } + +[] +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" + } + +[] +let ``Call text-returning method with explicit CancellationToken``() = + task { + use cts = new CancellationTokenSource() + let! result = api.GetApiReturnPlain(cts.Token) + result |> shouldEqual "Hello world" + } + +[] +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) -> () + } + +[] +let ``Call async POST generated method with explicit CancellationToken``() = + async { + use cts = new CancellationTokenSource() + let! result = apiAsync.PostApiReturnString(cts.Token) + result |> shouldEqual "Hello world" + } From 76869970ee2d28fe211e78566a38d06a23893718 Mon Sep 17 00:00:00 2001 From: Sergey Tihon Date: Wed, 25 Mar 2026 20:48:19 +0100 Subject: [PATCH 13/13] fix: async tests --- .../v3/Swashbuckle.CancellationToken.Tests.fs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs index 0b6f89a..4e1d4ba 100644 --- a/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs @@ -68,6 +68,7 @@ let ``Call async generated method without CancellationToken uses default token`` let! result = apiAsync.GetApiReturnBoolean() result |> shouldEqual true } + |> Async.StartAsTask [] let ``Call method with required param and explicit CancellationToken``() = @@ -92,6 +93,7 @@ let ``Call async generated method with explicit CancellationToken``() = let! result = apiAsync.GetApiReturnInt32(cts.Token) result |> shouldEqual 42 } + |> Async.StartAsTask [] let ``Call stream-returning method with explicit CancellationToken``() = @@ -124,6 +126,7 @@ let ``Call async generated method with already-cancelled token raises OperationC | :? OperationCanceledException -> () | :? AggregateException as aex when (aex.InnerException :? OperationCanceledException) -> () } + |> Async.StartAsTask [] let ``Call async POST generated method with explicit CancellationToken``() = @@ -132,3 +135,4 @@ let ``Call async POST generated method with explicit CancellationToken``() = let! result = apiAsync.PostApiReturnString(cts.Token) result |> shouldEqual "Hello world" } + |> Async.StartAsTask