diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c856feb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,122 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +# Read-only. Nothing here pushes, tags or releases, so the default write-capable token is +# more authority than the job needs. +permissions: + contents: read + +jobs: + build: + name: build + runs-on: ubuntu-latest + # The suite is slow for reasons tracked in #11: one test uses 100 retries with no base delay, + # so jitter saturates toward the 30 second cap and it alone accounts for most of the runtime. + # Generous enough not to flake, tight enough that a hang is not a 6 hour job. + timeout-minutes: 30 + + steps: + # persist-credentials: false because no step runs an authenticated git or gh command. + # Left on, checkout writes the token into .git/config where any later step, including + # anything a dependency pulls in, can read it. + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - run: dotnet restore Carom.sln + + # Release, because Release is what ships. Testing Debug while packing Release means the + # assembly consumers install has never had a test run against it. + - run: dotnet build Carom.sln --configuration Release --no-restore + + # No LogFileName. A solution with four test projects runs four assemblies, and a fixed + # name makes each one overwrite the last in the shared results directory, so only the + # final assembly's trx survives. The step below then counts 196 of 295 and, worse, would + # have counted a green run as complete if the floor happened to be lower. + - name: Test + run: > + dotnet test Carom.sln + --configuration Release + --no-build + --logger "trx" + --results-directory ${{ github.workspace }}/testresults + --verbosity normal + + # A run that discovers no tests exits zero, so the whole job can go green while proving + # nothing. Assert a floor and that everything found actually passed. + - name: Confirm tests actually ran + run: | + TOTAL=0 + PASSED=0 + for TRX in $(find "${{ github.workspace }}/testresults" -name '*.trx'); do + t=$(grep -o 'total="[0-9]*"' "$TRX" | head -1 | grep -o '[0-9]*') + p=$(grep -o 'passed="[0-9]*"' "$TRX" | head -1 | grep -o '[0-9]*') + TOTAL=$((TOTAL + ${t:-0})) + PASSED=$((PASSED + ${p:-0})) + done + echo "total=$TOTAL passed=$PASSED" + test "${TOTAL:-0}" -ge 250 || { echo "::error::Expected at least 250 tests, got ${TOTAL:-0}"; exit 1; } + test "$TOTAL" = "$PASSED" || { echo "::error::Not every test passed"; exit 1; } + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: testresults + path: ${{ github.workspace }}/testresults + + # Packs every packable project so a broken pack is a pull request failure rather than a + # surprise on release day. Three of these were absent from Carom.sln until this branch, so + # they were published while never being built by CI at all. + - name: Pack + run: dotnet pack Carom.sln --configuration Release --no-build --output ./artifacts + + # `ls -la` was diagnostic, not a check: it succeeds whenever any package exists, so a + # missing package or a newly-packable example would both have passed. The point of this + # branch is that the set is exactly right, so assert the set. + - name: Verify the package set + shell: bash + run: | + set -uo pipefail + expected="Carom Carom.AspNetCore Carom.DependencyInjection Carom.EntityFramework Carom.Extensions Carom.Http Carom.Telemetry.OpenTelemetry" + + # Strip the version and the .nupkg to get the package id. Symbol packages are excluded + # so a .snupkg alongside a .nupkg is not counted as a second package. + actual=$(find ./artifacts -maxdepth 1 -name '*.nupkg' ! -name '*.symbols.nupkg' -exec basename {} \; \ + | sed -E 's/\.[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?\.nupkg$//' | sort -u) + + echo "packed:"; printf ' %s\n' $actual + + fail=0 + for want in $expected; do + printf '%s\n' "$actual" | grep -Fxq "$want" || { echo "::error::Missing package: $want"; fail=1; } + done + + # Named explicitly rather than inferred. The example is IsPackable=false today, and this + # is what notices the day somebody removes that line. + if printf '%s\n' "$actual" | grep -Fxq "Carom.Examples.WebApi"; then + echo "::error::Carom.Examples.WebApi was packed. The example must not ship as a package." + fail=1 + fi + + count=$(printf '%s\n' "$actual" | grep -c . || true) + want_count=$(printf '%s\n' $expected | grep -c .) + if [ "$count" -ne "$want_count" ]; then + echo "::error::Expected exactly $want_count packages, found $count. A new packable project needs adding to this list." + fail=1 + fi + + test "$fail" -eq 0 || exit 1 + echo "Package set is exactly right." diff --git a/Carom.sln b/Carom.sln index 28d036a..e7514e2 100644 --- a/Carom.sln +++ b/Carom.sln @@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Carom.EntityFramework", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Carom.Telemetry.OpenTelemetry", "src\Carom.Telemetry.OpenTelemetry\Carom.Telemetry.OpenTelemetry.csproj", "{165F5DE0-AD25-479F-B660-D8641C23E0B0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Carom.ApiApproval.Tests", "tests\Carom.ApiApproval.Tests\Carom.ApiApproval.Tests.csproj", "{F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -187,6 +189,18 @@ Global {165F5DE0-AD25-479F-B660-D8641C23E0B0}.Release|x64.Build.0 = Release|Any CPU {165F5DE0-AD25-479F-B660-D8641C23E0B0}.Release|x86.ActiveCfg = Release|Any CPU {165F5DE0-AD25-479F-B660-D8641C23E0B0}.Release|x86.Build.0 = Release|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|x64.ActiveCfg = Debug|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|x64.Build.0 = Debug|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|x86.ActiveCfg = Debug|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Debug|x86.Build.0 = Debug|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|Any CPU.Build.0 = Release|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|x64.ActiveCfg = Release|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|x64.Build.0 = Release|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|x86.ActiveCfg = Release|Any CPU + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -197,9 +211,10 @@ Global {49E6808A-11DE-48ED-A12B-9236B6BE010D} = {92C92E59-A76E-4AB2-842A-AF22E07901C0} {10DFCF10-62F1-4CFF-A5BF-163FB31508B9} = {1F64DBA5-AC8A-4CA3-8A49-E423468BC3F8} {E0693F4E-5901-453D-A6AD-E23F9AC0BE71} = {C595A6A7-8B69-4CC0-90FB-DD1D573D7352} + {764E1709-5079-4CD4-95A8-281C03D42832} = {92C92E59-A76E-4AB2-842A-AF22E07901C0} {58DC8BE3-799C-4BC3-B614-2387BA262BCA} = {C595A6A7-8B69-4CC0-90FB-DD1D573D7352} {9B527C2A-65D5-4B62-99ED-6ED7F0265B40} = {C595A6A7-8B69-4CC0-90FB-DD1D573D7352} {165F5DE0-AD25-479F-B660-D8641C23E0B0} = {C595A6A7-8B69-4CC0-90FB-DD1D573D7352} - {764E1709-5079-4CD4-95A8-281C03D42832} = {92C92E59-A76E-4AB2-842A-AF22E07901C0} + {F448FBD4-64EC-46AA-8949-3B4D3BFF1E20} = {92C92E59-A76E-4AB2-842A-AF22E07901C0} EndGlobalSection EndGlobal diff --git a/examples/Carom.Examples.WebApi/Carom.Examples.WebApi.csproj b/examples/Carom.Examples.WebApi/Carom.Examples.WebApi.csproj index cf6a997..4a23339 100644 --- a/examples/Carom.Examples.WebApi/Carom.Examples.WebApi.csproj +++ b/examples/Carom.Examples.WebApi/Carom.Examples.WebApi.csproj @@ -3,6 +3,9 @@ Exe net8.0 + + false latest enable enable diff --git a/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.AspNetCore.approved.txt b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.AspNetCore.approved.txt new file mode 100644 index 0000000..ce45b67 --- /dev/null +++ b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.AspNetCore.approved.txt @@ -0,0 +1,17 @@ +namespace Carom.AspNetCore +{ + public sealed class CaromCircuitBreakerHealthCheck : Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck + { + public CaromCircuitBreakerHealthCheck(string serviceName) { } + public System.Threading.Tasks.Task CheckHealthAsync(Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckContext context, System.Threading.CancellationToken cancellationToken = default) { } + } + public class CaromHealthCheck : Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck + { + public CaromHealthCheck(string name, System.Func> healthCheckFunc) { } + public System.Threading.Tasks.Task CheckHealthAsync(Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckContext context, System.Threading.CancellationToken cancellationToken = default) { } + } + public static class CaromServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCaromCircuitBreaker(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string serviceName, string? name = null, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, string[]? tags = null) { } + } +} \ No newline at end of file diff --git a/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.DependencyInjection.approved.txt b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.DependencyInjection.approved.txt new file mode 100644 index 0000000..388d1bb --- /dev/null +++ b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.DependencyInjection.approved.txt @@ -0,0 +1,56 @@ +namespace Carom.DependencyInjection +{ + public static class CaromServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddCaromResilience(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configureAll) { } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddCaromResilience(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, string name, System.Action configure) { } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddCaromResilienceRegistry(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { } + } + public interface IResiliencePipelineConfigurator + { + Carom.DependencyInjection.IResiliencePipelineConfigurator AddPipeline(string name, System.Action configure); + } + public interface IResiliencePipelineRegistry + { + Carom.DependencyInjection.ResiliencePipeline GetPipeline(string name); + bool TryGetPipeline(string name, out Carom.DependencyInjection.ResiliencePipeline? pipeline); + } + public interface IResilienceStrategy + { + T Execute(System.Func action); + System.Threading.Tasks.Task ExecuteAsync(System.Func> action, System.Threading.CancellationToken ct); + } + public class ResiliencePipeline + { + public string Name { get; } + public void Execute(System.Action action) { } + public T Execute(System.Func action) { } + public System.Threading.Tasks.Task ExecuteAsync(System.Func action, System.Threading.CancellationToken ct = default) { } + public System.Threading.Tasks.Task ExecuteAsync(System.Func action, System.Threading.CancellationToken ct = default) { } + public System.Threading.Tasks.Task ExecuteAsync(System.Func> action, System.Threading.CancellationToken ct = default) { } + public System.Threading.Tasks.Task ExecuteAsync(System.Func> action, System.Threading.CancellationToken ct = default) { } + } + public class ResiliencePipelineBuilder + { + public ResiliencePipelineBuilder(string name) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddBulkhead(string resourceKey, Carom.Extensions.Compartment config) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddBulkhead(string resourceKey, int maxConcurrency, int queueDepth = 0) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddCircuitBreaker(string serviceKey, Carom.Extensions.Cushion config) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddCircuitBreaker(string serviceKey, int failureThreshold, int samplingWindow) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddFallback(System.Func fallback) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddFallback(TResult fallbackValue) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddRateLimit(string serviceKey, Carom.Extensions.Throttle config) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddRateLimit(string serviceKey, int maxRequests, System.TimeSpan timeWindow) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddRetry(Carom.Bounce config) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddRetry(int retries = 3) { } + public Carom.DependencyInjection.ResiliencePipelineBuilder AddTimeout(System.TimeSpan timeout) { } + public Carom.DependencyInjection.ResiliencePipeline Build() { } + } + public class ResiliencePipelineRegistry : Carom.DependencyInjection.IResiliencePipelineRegistry + { + public ResiliencePipelineRegistry() { } + public Carom.DependencyInjection.ResiliencePipeline GetPipeline(string name) { } + public void Register(string name, Carom.DependencyInjection.ResiliencePipeline pipeline) { } + public bool TryGetPipeline(string name, out Carom.DependencyInjection.ResiliencePipeline? pipeline) { } + } +} \ No newline at end of file diff --git a/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.EntityFramework.approved.txt b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.EntityFramework.approved.txt new file mode 100644 index 0000000..c213ede --- /dev/null +++ b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.EntityFramework.approved.txt @@ -0,0 +1,9 @@ +namespace Carom.EntityFramework +{ + public static class CaromDbContextExtensions + { + public static System.Threading.Tasks.Task ExecuteWithRetryAsync(this Microsoft.EntityFrameworkCore.DbContext context, System.Func> operation, int retries = 3, System.Threading.CancellationToken cancellationToken = default) { } + public static System.Threading.Tasks.Task SaveChangesWithRetryAsync(this Microsoft.EntityFrameworkCore.DbContext context, Carom.Bounce bounce, System.Threading.CancellationToken cancellationToken = default) { } + public static System.Threading.Tasks.Task SaveChangesWithRetryAsync(this Microsoft.EntityFrameworkCore.DbContext context, int retries = 3, System.Threading.CancellationToken cancellationToken = default) { } + } +} \ No newline at end of file diff --git a/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.Extensions.approved.txt b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.Extensions.approved.txt new file mode 100644 index 0000000..4fed7ca --- /dev/null +++ b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.Extensions.approved.txt @@ -0,0 +1,122 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Carom.DependencyInjection")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Carom.Extensions.Tests")] +namespace Carom.Extensions +{ + public static class CaromCompartmentExtensions + { + public static T Shot(System.Func action, Carom.Extensions.Compartment compartment, Carom.Bounce bounce) { } + public static T Shot(System.Func action, Carom.Extensions.Compartment compartment, int retries = 3, System.TimeSpan? baseDelay = default, System.Func? shouldBounce = null, bool disableJitter = false) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func> action, Carom.Extensions.Compartment compartment, Carom.Bounce bounce, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func> action, Carom.Extensions.Compartment compartment, int retries = 3, System.TimeSpan? baseDelay = default, System.Func? shouldBounce = null, bool disableJitter = false, System.Threading.CancellationToken ct = default) { } + } + public static class CaromCushionExtensions + { + public static T Shot(System.Func action, Carom.Extensions.Cushion cushion, Carom.Bounce bounce) { } + public static T Shot(System.Func action, Carom.Extensions.Cushion cushion, int retries = 3, System.TimeSpan? baseDelay = default, System.Func? shouldBounce = null, bool disableJitter = false) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func> action, Carom.Extensions.Cushion cushion, Carom.Bounce bounce, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func> action, Carom.Extensions.Cushion cushion, int retries = 3, System.TimeSpan? baseDelay = default, System.Func? shouldBounce = null, bool disableJitter = false, System.Threading.CancellationToken ct = default) { } + } + public static class CaromFallbackExtensions + { + public static T Pocket(this System.Func action, System.Func fallbackFn) { } + public static T Pocket(this System.Func action, System.Func fallbackFn) { } + public static T Pocket(this System.Func action, T fallback) { } + public static System.Threading.Tasks.Task PocketAsync(this System.Func> action, System.Func> fallbackFn, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task PocketAsync(this System.Func> action, System.Func fallbackFn, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task PocketAsync(this System.Func> action, System.Func> fallbackFn, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task PocketAsync(this System.Func> action, T fallback, System.Threading.CancellationToken ct = default) { } + public static T ShotWithPocket(System.Func action, T fallback, int retries = 3, System.TimeSpan? baseDelay = default) { } + public static System.Threading.Tasks.Task ShotWithPocketAsync(System.Func> action, T fallback, int retries = 3, System.TimeSpan? baseDelay = default, System.Threading.CancellationToken ct = default) { } + } + public static class CaromMasseExtensions + { + public static System.Threading.Tasks.Task ShotWithHedgingAsync(System.Func> action, Carom.Extensions.Masse config, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task ShotWithHedgingAsync(System.Func> action, Carom.Extensions.Masse config, System.Threading.CancellationToken ct = default) { } + } + public static class CaromThrottleExtensions + { + public static T Shot(System.Func action, Carom.Extensions.Throttle throttle, Carom.Bounce bounce) { } + public static T Shot(System.Func action, Carom.Extensions.Throttle throttle, int retries = 3, System.TimeSpan? baseDelay = default, System.Func? shouldBounce = null, bool disableJitter = false) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func> action, Carom.Extensions.Throttle throttle, Carom.Bounce bounce, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func> action, Carom.Extensions.Throttle throttle, int retries = 3, System.TimeSpan? baseDelay = default, System.Func? shouldBounce = null, bool disableJitter = false, System.Threading.CancellationToken ct = default) { } + } + public class CircuitOpenException : System.Exception + { + public CircuitOpenException(string serviceKey) { } + public CircuitOpenException(string serviceKey, System.Exception innerException) { } + public string ServiceKey { get; } + } + public enum CircuitState + { + Closed = 0, + Open = 1, + HalfOpen = 2, + } + public readonly struct Compartment + { + public int MaxConcurrency { get; } + public int QueueDepth { get; } + public string ResourceKey { get; } + public static Carom.Extensions.CompartmentBuilder ForResource(string resourceKey) { } + } + public class CompartmentBuilder + { + public Carom.Extensions.Compartment Build() { } + public Carom.Extensions.CompartmentBuilder WithMaxConcurrency(int max) { } + public Carom.Extensions.CompartmentBuilder WithQueueDepth(int depth) { } + } + public class CompartmentFullException : System.Exception + { + public CompartmentFullException(string resourceKey, int maxConcurrency) { } + public CompartmentFullException(string resourceKey, int maxConcurrency, System.Exception innerException) { } + public int MaxConcurrency { get; } + public string ResourceKey { get; } + } + public readonly struct Cushion + { + public int FailureThreshold { get; } + public System.TimeSpan HalfOpenDelay { get; } + public int SamplingWindow { get; } + public string ServiceKey { get; } + public static Carom.Extensions.CushionBuilder ForService(string serviceKey) { } + public static Carom.Extensions.CircuitState? GetState(string serviceKey) { } + } + public class CushionBuilder + { + public Carom.Extensions.Cushion HalfOpenAfter(System.TimeSpan delay) { } + public Carom.Extensions.CushionBuilder OpenAfter(int failures, int within) { } + } + public readonly struct Masse + { + public bool CancelPendingOnSuccess { get; } + public System.TimeSpan HedgeDelay { get; } + public int MaxHedgedAttempts { get; } + public System.Func? ShouldHedge { get; } + public Carom.Extensions.Masse After(System.TimeSpan delay) { } + public Carom.Extensions.Masse When(System.Func predicate) { } + public Carom.Extensions.Masse WithCancellation(bool cancel) { } + public static Carom.Extensions.Masse WithAttempts(int count) { } + } + public readonly struct Throttle + { + public int BurstSize { get; } + public int MaxRequests { get; } + public string ServiceKey { get; } + public System.TimeSpan TimeWindow { get; } + public static Carom.Extensions.ThrottleBuilder ForService(string serviceKey) { } + } + public class ThrottleBuilder + { + public Carom.Extensions.Throttle Build() { } + public Carom.Extensions.ThrottleBuilder WithBurst(int burstSize) { } + public Carom.Extensions.ThrottleBuilder WithRate(int maxRequests, System.TimeSpan per) { } + } + public class ThrottledException : System.Exception + { + public ThrottledException(string serviceKey, int maxRequests, System.TimeSpan timeWindow) { } + public ThrottledException(string serviceKey, int maxRequests, System.TimeSpan timeWindow, System.Exception innerException) { } + public int MaxRequests { get; } + public string ServiceKey { get; } + public System.TimeSpan TimeWindow { get; } + } +} \ No newline at end of file diff --git a/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.Http.approved.txt b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.Http.approved.txt new file mode 100644 index 0000000..a26fdf5 --- /dev/null +++ b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.Http.approved.txt @@ -0,0 +1,25 @@ +namespace Carom.Http +{ + public static class CaromHttpExtensions + { + public static Microsoft.Extensions.DependencyInjection.IHttpClientBuilder AddCaromResilience(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder builder) { } + public static Microsoft.Extensions.DependencyInjection.IHttpClientBuilder AddCaromResilience(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder builder, Carom.Bounce config) { } + public static Microsoft.Extensions.DependencyInjection.IHttpClientBuilder AddCaromResilience(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder builder, System.Func configure) { } + public static Microsoft.Extensions.DependencyInjection.IHttpClientBuilder AddCaromResilience(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder builder, int retries) { } + } + public class CaromHttpHandler : System.Net.Http.DelegatingHandler + { + public CaromHttpHandler() { } + public CaromHttpHandler(Carom.Bounce config) { } + public CaromHttpHandler(int retries) { } + public CaromHttpHandler(System.Net.Http.HttpMessageHandler innerHandler) { } + public CaromHttpHandler(System.Net.Http.HttpMessageHandler innerHandler, Carom.Bounce config) { } + public bool RetryNonIdempotentRequests { get; set; } + protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + } + public class TransientHttpException : System.Net.Http.HttpRequestException + { + public TransientHttpException(string message, System.Net.HttpStatusCode statusCode) { } + public System.Net.HttpStatusCode StatusCode { get; } + } +} \ No newline at end of file diff --git a/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.Telemetry.OpenTelemetry.approved.txt b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.Telemetry.OpenTelemetry.approved.txt new file mode 100644 index 0000000..806fecf --- /dev/null +++ b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.Telemetry.OpenTelemetry.approved.txt @@ -0,0 +1,13 @@ +namespace Carom.Telemetry.OpenTelemetry +{ + public static class CaromTelemetry + { + public const string ActivitySourceName = "Carom"; + public const string MeterName = "Carom"; + public static void RecordBulkheadRejection(string resourceKey) { } + public static void RecordCircuitBreakerOpen(string serviceName) { } + public static void RecordRateLimitRejection(string serviceKey) { } + public static void RecordRetry(int attempt, double delayMs, string? exceptionType = null) { } + public static System.Diagnostics.Activity? StartActivity(string operationName, System.Diagnostics.ActivityKind kind = 0) { } + } +} \ No newline at end of file diff --git a/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.approved.txt b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.approved.txt new file mode 100644 index 0000000..8935ac0 --- /dev/null +++ b/tests/Carom.ApiApproval.Tests/ApprovedApi/Carom.approved.txt @@ -0,0 +1,57 @@ +namespace Carom +{ + public readonly struct Bounce + { + public System.TimeSpan BaseDelay { get; } + public bool DisableJitter { get; } + public int Retries { get; } + public System.Func? ShouldBounce { get; } + public System.TimeSpan? Timeout { get; } + public Carom.Bounce When(System.Func predicate) { } + public Carom.Bounce WithDelay(System.TimeSpan delay) { } + public Carom.Bounce WithTimeout(System.TimeSpan timeout) { } + public Carom.Bounce WithoutJitter() { } + public static Carom.Bounce For(int retries = 3) { } + public static Carom.Bounce On(int retries = 3) + where TException : System.Exception { } + public static Carom.Bounce Times(int count = 3) { } + } + public readonly struct Bounce + { + public System.TimeSpan BaseDelay { get; } + public bool DisableJitter { get; } + public int Retries { get; } + public System.Func? ShouldBounce { get; } + public System.Func? ShouldRetryResult { get; } + public System.TimeSpan? Timeout { get; } + public Carom.Bounce When(System.Func predicate) { } + public Carom.Bounce WhenResult(System.Func predicate) { } + public Carom.Bounce WithDelay(System.TimeSpan delay) { } + public Carom.Bounce WithTimeout(System.TimeSpan timeout) { } + public Carom.Bounce WithoutJitter() { } + public static Carom.Bounce On(int retries = 3) + where TException : System.Exception { } + public static Carom.Bounce Times(int count = 3) { } + } + public static class Carom + { + public static void Shot(System.Action action, Carom.Bounce bounce) { } + public static void Shot(System.Action action, int retries = 3, System.TimeSpan? baseDelay = default, System.Func? shouldBounce = null, bool disableJitter = false) { } + public static T Shot(System.Func action, Carom.Bounce bounce) { } + public static T Shot(System.Func action, Carom.Bounce bounce) { } + public static T Shot(System.Func action, int retries = 3, System.TimeSpan? baseDelay = default, System.Func? shouldBounce = null, bool disableJitter = false) { } + public static T Shot(System.Func action, int retries, System.TimeSpan? baseDelay, System.Func? shouldBounce, System.Func? shouldRetryResult, bool disableJitter = false) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func action, Carom.Bounce bounce, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func action, int retries = 3, System.TimeSpan? baseDelay = default, System.TimeSpan? timeout = default, System.Func? shouldBounce = null, bool disableJitter = false, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func> action, Carom.Bounce bounce, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func> action, Carom.Bounce bounce, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func> action, int retries = 3, System.TimeSpan? baseDelay = default, System.TimeSpan? timeout = default, System.Func? shouldBounce = null, bool disableJitter = false, System.Threading.CancellationToken ct = default) { } + public static System.Threading.Tasks.Task ShotAsync(System.Func> action, int retries, System.TimeSpan? baseDelay, System.TimeSpan? timeout, System.Func? shouldBounce, System.Func? shouldRetryResult, bool disableJitter = false, System.Threading.CancellationToken ct = default) { } + } + public class TimeoutRejectedException : System.OperationCanceledException + { + public TimeoutRejectedException(System.TimeSpan timeout) { } + public TimeoutRejectedException(System.TimeSpan timeout, System.Exception innerException) { } + public System.TimeSpan Timeout { get; } + } +} \ No newline at end of file diff --git a/tests/Carom.ApiApproval.Tests/Carom.ApiApproval.Tests.csproj b/tests/Carom.ApiApproval.Tests/Carom.ApiApproval.Tests.csproj new file mode 100644 index 0000000..870bf95 --- /dev/null +++ b/tests/Carom.ApiApproval.Tests/Carom.ApiApproval.Tests.csproj @@ -0,0 +1,37 @@ + + + + net8.0 + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Carom.ApiApproval.Tests/PublicApiTests.cs b/tests/Carom.ApiApproval.Tests/PublicApiTests.cs new file mode 100644 index 0000000..787cfc2 --- /dev/null +++ b/tests/Carom.ApiApproval.Tests/PublicApiTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using PublicApiGenerator; +using Xunit; + +namespace Carom.ApiApproval.Tests; + +/// +/// Pins the public surface of every published package. +/// +/// Carom invites contributions, and a library that invites strangers without a machine-checked +/// public surface can have a well meant first pull request break every consumer with all other +/// checks green. That is what this exists to stop. +/// +/// These packages are consumed by other people's code, so any change to a signature, an +/// accessibility, a base type or a default parameter is a breaking change for someone even +/// when every behavioural test still passes. Rendering the surface to text and comparing it +/// to a checked-in file turns that into a failing test and a reviewable diff. +/// +/// When this test fails it is not necessarily wrong. Read the diff, decide whether the change +/// is additive or breaking, then approve it by copying the .received.txt over the .approved.txt +/// and committing both that and the version bump the change implies. +/// +public class PublicApiTests +{ + public static IEnumerable Packages => new[] + { + new object[] { "Carom" }, + new object[] { "Carom.AspNetCore" }, + new object[] { "Carom.DependencyInjection" }, + new object[] { "Carom.EntityFramework" }, + new object[] { "Carom.Extensions" }, + new object[] { "Carom.Http" }, + new object[] { "Carom.Telemetry.OpenTelemetry" } + }; + + [Theory] + [MemberData(nameof(Packages))] + public void PublicApiHasNotChanged(string package) + { + var assembly = Assembly.Load(package); + + var actual = assembly.GeneratePublicApi(new ApiGeneratorOptions + { + // Attributes the build emits rather than the author writes. They are noise in a + // diff and they change with tooling upgrades, not with the API. + ExcludeAttributes = new[] + { + "System.Diagnostics.DebuggerNonUserCodeAttribute", + "System.Runtime.CompilerServices.CompilerGeneratedAttribute", + "System.Runtime.CompilerServices.RefSafetyRulesAttribute", + "System.Runtime.Versioning.TargetFrameworkAttribute", + "System.Reflection.AssemblyMetadataAttribute", + "System.Reflection.AssemblyCompanyAttribute", + "System.Reflection.AssemblyConfigurationAttribute", + "System.Reflection.AssemblyFileVersionAttribute", + "System.Reflection.AssemblyInformationalVersionAttribute", + "System.Reflection.AssemblyProductAttribute", + "System.Reflection.AssemblyTitleAttribute", + "System.Reflection.AssemblyVersionAttribute" + } + }).Trim().ReplaceLineEndings("\n"); + + var approvedPath = ApprovedPathFor(package); + var receivedPath = approvedPath.Replace(".approved.txt", ".received.txt"); + + if (!File.Exists(approvedPath)) + { + File.WriteAllText(receivedPath, actual); + Assert.Fail( + $"No approved API file for {package}. A new package needs its surface approved once.\n" + + $"Review {receivedPath} and, if it is what you meant to publish, rename it to {Path.GetFileName(approvedPath)}."); + } + + var approved = File.ReadAllText(approvedPath).Trim().ReplaceLineEndings("\n"); + + if (approved == actual) + { + // Leave nothing behind from an earlier failing run. + if (File.Exists(receivedPath)) + { + File.Delete(receivedPath); + } + + return; + } + + File.WriteAllText(receivedPath, actual); + + Assert.Fail( + $"The public API of {package} changed.\n\n" + + $"{DescribeDiff(approved, actual)}\n" + + $"If the change is intended, copy\n {receivedPath}\nover\n {approvedPath}\n" + + "and make sure the version bump matches: additions are a minor, anything removed or " + + "changed in place is a major."); + } + + private static string ApprovedPathFor(string package) + { + // Walk up from the test binaries to the project directory so the approved files are + // edited and committed in source rather than in bin. + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Carom.ApiApproval.Tests.csproj"))) + { + directory = directory.Parent; + } + + var root = directory?.FullName ?? AppContext.BaseDirectory; + var approvedDirectory = Path.Combine(root, "ApprovedApi"); + Directory.CreateDirectory(approvedDirectory); + + return Path.Combine(approvedDirectory, $"{package}.approved.txt"); + } + + private static string DescribeDiff(string approved, string actual) + { + var before = approved.Split('\n'); + var after = actual.Split('\n'); + + var removed = before.Except(after).ToArray(); + var added = after.Except(before).ToArray(); + + var lines = new List(); + + if (removed.Length > 0) + { + lines.Add($"Removed or changed ({removed.Length}):"); + lines.AddRange(removed.Take(25).Select(l => $" - {l.Trim()}")); + if (removed.Length > 25) + { + lines.Add($" ... and {removed.Length - 25} more"); + } + } + + if (added.Length > 0) + { + lines.Add($"Added ({added.Length}):"); + lines.AddRange(added.Take(25).Select(l => $" + {l.Trim()}")); + if (added.Length > 25) + { + lines.Add($" ... and {added.Length - 25} more"); + } + } + + return string.Join("\n", lines); + } +}