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/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/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/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/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs b/src/SwaggerProvider.DesignTime/v3/OperationCompiler.fs index 2eb5fcc..1782213 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 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,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, 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 = @@ -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(ctArg) + let parameters = - List.tail args // skip `this` param + apiArgs |> List.choose (function | ShapeVar sVar as expr -> let param = @@ -392,17 +421,18 @@ 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) } @> @@ -410,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 } @> @@ -421,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 } @> @@ -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) diff --git a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs index 44f12cf..62e8da3 100644 --- a/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs +++ b/src/SwaggerProvider.Runtime/ProvidedApiClientBase.fs @@ -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 = + 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 @@ -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. 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/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..4e1d4ba --- /dev/null +++ b/tests/SwaggerProvider.ProviderTests/v3/Swashbuckle.CancellationToken.Tests.fs @@ -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) + +[] +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 { + 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" + } + +[] +let ``Call async generated method without CancellationToken uses default token``() = + async { + let! result = apiAsync.GetApiReturnBoolean() + result |> shouldEqual true + } + |> Async.StartAsTask + +[] +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 + } + |> Async.StartAsTask + +[] +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) -> () + } + |> Async.StartAsTask + +[] +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 diff --git a/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs b/tests/SwaggerProvider.Tests/RuntimeHelpersTests.fs index 1933a32..ca0b19a 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) @@ -446,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) () }) @@ -467,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) () }) @@ -489,7 +490,38 @@ 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) + () + }) + + () + } + + [] + 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) () }) 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"