Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Preventing breaking changes in public APIs with PublicApiGenerator

$
0
0

In this post I shows how to use two open source libraries to keep track of the public API surface of any NuGet libraries (or any other code) you create. The approach shown in this post uses a unit test to enforce that changes to your public API are documented and don't change unexpectedly.

Why do you need to track your public API surface?

When you're creating a .NET library for public consumption on NuGet you need to be very careful about what APIs you expose. NuGet libraries generally use semantic versioning which means that breaking/incompatible changes to your public API should only occur in major version bumps.

"What is a breaking change?" is, unfortunately, rather more nebulous than this rule. Changes to method and type signatures are generally breaking, but behavioural changes could also be breaking, even if the exposed types and methods don't change.

The public APIs (types/methods) that your library exposes and which consumers interact with are the most obvious thing that you need to be careful about how you change. Introducing a new public method on a type would generally not be considered a breaking change, whereas removing a method definitely would be a breaking change.

The rules of semver are such that you need to really think about the changes to your public API. Unfortunately, if you have a big library, keeping track of what your public API is can be easier said than done.

Luckily, there are various tools you can use for tracking your public API, and alerting you to changes, so that you don't change it accidentally.

Using an analyzer to document your public API

Microsoft is the creator of a huge number of public libraries (the base class library in the .NET runtime!) and so need a way of keeping track of these APIs themselves. They therefore have an analyzer, which helps document this for you.

To use the analyzer, add the Microsoft.CodeAnalysis.PublicApiAnalyzers package to your project, for example by running

dotnet add package Microsoft.CodeAnalysis.PublicApiAnalyzers

After you do this, your whole project will be covered in errors, and your build will be filled with issues:

Error RS0016 : Symbol 'HeaderPolicyCollectionExtensions' is not part of the declared public API (https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)
Error RS0016 : Symbol 'ContentSecurityPolicyHeaderExtensions' is not part of the declared public API (https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)
Error RS0016 : Symbol 'HeaderPolicyCollection' is not part of the declared public API (https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)
Error RS0016 : Symbol 'implicit constructor for 'HeaderPolicyCollection'' is not part of the declared public API (https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)
...

These errors all indicate that you haven't recorded the public API in the public API document. To do that, create two files in your project:

  • PublicAPI.Shipped.txt
  • PublicAPI.Unshipped.txt

You can then use the code-fix for the analyzer action on a public API to add it to the documented APIs:

A public API that needs to be documented in Rider

If you're using Visual Studio, you can use the "fix all in solution" option to move all your current public APIs into the documentation files, but unfortunately that doesn't work in Rider 🙁. As a workaround (or if you're using the command line) you can run the following, and it will do the same thing.

dotnet format analyzers --diagnostics=RS0016

The end result is that PublicAPI.Unshipped.txt will contain a list of all your public APIs, for example:

abstract NetEscapades.AspNetCore.SecurityHeaders.Headers.HeaderPolicyBase.GetValue(Microsoft.AspNetCore.Http.HttpContext context) -> string
abstract NetEscapades.AspNetCore.SecurityHeaders.Headers.HeaderPolicyBase.Header.get -> string
const NetEscapades.AspNetCore.SecurityHeaders.Headers.StrictTransportSecurityHeader.OneYearInSeconds = 31536000 -> int
Microsoft.AspNetCore.Builder.ContentSecurityPolicyHeaderExtensions
Microsoft.AspNetCore.Builder.CrossOriginEmbedderPolicyBuilder
Microsoft.AspNetCore.Builder.CrossOriginEmbedderPolicyBuilder.Credentialless() -> NetEscapades.AspNetCore.SecurityHeaders.Headers.CrossOriginPolicies.EmbedderPolicy.CredentiallessDirectiveBuilder
Microsoft.AspNetCore.Builder.CrossOriginEmbedderPolicyBuilder.CrossOriginEmbedderPolicyBuilder() -> void

By default, the analyzer adds APIs to PublicAPI.Unshipped.txt. It's the developer's responsibility to move the APIs across to PublicAPI.Shipped.txt when they're actually shipped to customers.

I've covered this approach quite briefly because, basically, I don't like it. 😅 I have a variety of gripes with it:

  • I find it tedious that the analyzer breaks the build every time I add a public type, even if I'm just trying something out. I either have to add the API to the documentation file (even if I haven't decided what it should like yet) or I have to explicitly make it internal.
    • There are obviously other workarounds: you could set the diagnostic to be warning or information only when running locally and only break the build in CI but I'm still not a fan of that friction.
  • Having "Shipped" and "Unshipped" APIs seems completely unnecessary to me, especially when everything is versioned in Git. Maybe this makes sense for a system as complex as the ASP.NET Core libraries, but I just don't see the need, and again, it adds confusion and friction.
  • The format of the API files just bugs me 😅 Why invent a whole new format for APIs? We have C# method signatures, can't we just use them?

Obviously the `Microsoft.CodeAnalysis.PublicApiAnalyzers1 package is very popular (despite my grumbling). The fact that it's used by Microsoft directly is a big endorsement, so if you want to learn more, I found this post about it a good start.

For the rest of this post I'm going to describe a different approach, one that I personally prefer.

Using the PublicApiGenerator package

The PublicApiGenerator library is an open source package that does what it says: it creates a string containing the public API of your library. From there, you can do anything you like with the string, but I like to use Verify (another open source project) to perform snapshot approvals of the generated API.

Overall, the process looks a bit like this:

  • Generate the public API for the library using PublicApiGenerator as a string.
  • Use Verify to persist the public API as a file in your test suite.
  • Create a unit test that verifies the public API has not changed.

As with all snapshot testing, the test fails if something has changed, and it's up to you to either accept the change (if the public API was supposed to change) or fix your code so as to not change the public API.

This addresses all my previous complaints about the analyzer approach:

  • You can run the unit test whenever you like. It doesn't stop you iterating on your APIs until you want it to. You can easily test locally by running the unit test, and CI will catch any issues automatically.
  • There's one set of Public APIs, versioned along with the code directly, so no concept of "Shipped" vs "Unshipped"
  • The output looks a lot like C#, with methods grouped inside types.

To give you more of an idea of how it works, I'll show how I quickly added PublicApiGenerator to one of my open source projects.

1. Add the necessary packages

First we need to add the required NuGet packages to our test project. The PublicApiGenerator package has very few dependencies, so it probably won't cause you any dependency issues:

dotnet add package PublicApiGenerator

I like to use Verify for my snapshot testing, and as we're going to use xunit for testing, we'll add the Verify.Xunit package:

dotnet add package Verify.Xunit

Depending on what target frameworks your library supports, you may have some issues with dependencies here. Verify has been quite aggressive with updating dependencies and target frameworks, so I often find I need to install an old version to resolve all the conflicts (I'm using 18.4.0 for example, when the latest is 26.3.1!). That said, the Verify usage is entirely optional, you can easily roll-your-own if you need to, as I have previously.

Once we've added the dependencies, our test project .csproj should look something like this:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFrameworks>net6.0;net8.0</TargetFrameworks>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="PublicApiGenerator" Version="11.1.0" />
    <PackageReference Include="Verify.Xunit" Version="18.4.0" />
    <PackageReference Include="xunit" Version="2.4.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.3.0" />
  </ItemGroup>

</Project>

Now we can create the test itself.

2. Create the unit test

The simplest version of our unit test looks something like the following:

using System.Threading.Tasks;
using PublicApiGenerator;
using VerifyXunit;
using Xunit;

[UsesVerify]
public class PublicApiTest
{
    [Fact]
    public Task PublicApiHasNotChanged()
    {
        // Get the assembly for the library we want to document
        Assembly assembly = typeof(MyType).Assembly;

        // Retreive the public API for all types in the assembly
        string publicApi = assembly.GeneratePublicApi();

        // Run a snapshot test on the returned string
        return Verifier.Verify(publicApi);
    }
}

This example is hopefully easy to follow. We start with a "marker" type as a way of retrieving an instance of the Assembly we want to document. We then call GeneratePublicApi() to retrieve the string representation of the API. Finally, we use Verify to perform a snapshot test of this value.

The first time the test runs, Verify creates a file called PublicApiTest.PublicApiHasNotChanged.verified.txt, based on the name of the test. As this is empty, the test fails, but you can use Verify's various tools to update the snapshot. When you re-run the test it will pass.

If you open up the PublicApiTest.PublicApiHasNotChanged.verified.txt file, you'll see that it looks a lot like "normal" C#, but with all the method bodies stripped out, for example:

[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/andrewlock/NetEscapades.AspNetCore.SecurityHeaders")]
[assembly: System.Resources.NeutralResourcesLanguage("en-GB")]
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("NetEscapades.AspNetCore.SecurityHeaders.TagHelpers.Test")]
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("NetEscapades.AspNetCore.SecurityHeaders.Test")]
[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v3.0", FrameworkDisplayName=".NET Core 3.0")]
namespace NetEscapades.AspNetCore.SecurityHeaders.Headers.ContentSecurityPolicy
{
    public class BaseUriDirectiveBuilder : NetEscapades.AspNetCore.SecurityHeaders.Headers.ContentSecurityPolicy.CspDirectiveBuilder
    {
        public BaseUriDirectiveBuilder() { }
    }
    public class BlockAllMixedContentDirectiveBuilder : NetEscapades.AspNetCore.SecurityHeaders.Headers.ContentSecurityPolicy.CspDirectiveBuilderBase
    {
        public BlockAllMixedContentDirectiveBuilder() { }
    }
    public class ConnectSourceDirectiveBuilder : NetEscapades.AspNetCore.SecurityHeaders.Headers.ContentSecurityPolicy.CspDirectiveBuilder
    {
        public ConnectSourceDirectiveBuilder() { }
    }
    public class CspDirectiveBuilder : NetEscapades.AspNetCore.SecurityHeaders.Headers.ContentSecurityPolicy.CspDirectiveBuilderBase
    {
        public CspDirectiveBuilder(string directive) { }
        public bool BlockResources { get; set; }
        public System.Collections.Generic.List<string> Sources { get; }
    }
}

This is just part of the public API for NetEscapades.AspNetCore.SecurityHeaders, but as you can see it looks a lot like "normal" C#.

If you make a change to the public API, then when you run the test Verify will fail, and shows you a diff with exactly what changed:

An example of a diff of the image when something changes

That's basically all there is to it. You can use the public API string in any way that makes sense. I like to use it as a sense-check on the changes that I'm making. It doesn't differentiate between breaking or non-breaking changes, but it makes it very apparent if you've removed or changed anything, which is typically good enough (and is the same functionality the analyzer provides).

Customizing what counts as your public API

You may notice at the top of the snapshot in the previous section there's a number of "standard" attributes that are technically part of your public API, but which you probably don't think of as such, as they're applied to the assembly:

[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/andrewlock/NetEscapades.AspNetCore.SecurityHeaders")]
[assembly: System.Resources.NeutralResourcesLanguage("en-GB")]
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("NetEscapades.AspNetCore.SecurityHeaders.TagHelpers.Test")]
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("NetEscapades.AspNetCore.SecurityHeaders.Test")]
[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v3.0", FrameworkDisplayName=".NET Core 3.0")]

The [InternalsVisibleTo] and [TargetFramework] attributes in particular can be annoying. The former tends to change if you want to expose APIs for test purposes to other test projects. The latter is added automatically by the compiler, and means that if you're compiling for multiple TargetFrameworks, then you won't be able to share snapshot files, even if your APIs are otherwise identical.

If you intend to have different public APIs for different framework, you'll need to use the UniqueForRuntime() option on Verify, so that each target framework test run gets a different snapshot.

PublicApiGenerator lets you completely customize which attributes to include or exclude, and even lets you specify exactly which types should be considered as part of your public API. By default, for example, PublicApiGenerator excludes any types declared in your assembly that are in a Microsoft or System namespace.

In general you probably shouldn't declare types in Microsoft or System namespaces, but early in ASP.NET Core's lifetime a convention emerged of putting extensions on IServiceCollection or IApplicationBuilder, to improve discoverability. By default, PublicApiGenerator excludes these from your public API.

For example, the following code shows how you can remove some of those annoying assembly attributes, and also ensure that all the types in your assembly are included, regardless of the namespace.

[Fact]
public Task PublicApiHasNotChanged()
{
    var assembly = typeof(HeaderPolicyCollection).Assembly;
    var options = new ApiGeneratorOptions
    {
        // These attributes won't be included in the public API
        ExcludeAttributes =
        [
            typeof(InternalsVisibleToAttribute).FullName,
            "System.Runtime.CompilerServices.IsByRefLike",
            typeof(TargetFrameworkAttribute).FullName,
        ],
        // By default types found in Microsoft or System 
        // namespaces are not treated as part of the public API.
        // By passing an empty array, we ensure they're all 
        DenyNamespacePrefixes = []
    };

    var publicApi = assembly.GeneratePublicApi(options);

    return Verifier.Verify(publicApi);
}

Another example of a tweak I have made in some cases is where types need to be public for technical reasons, but we don't want them to be visible to consumers of the library. A somewhat crude approach (that nevertheless works well enough) is to decorate the types with [Browsable] and [EditorBrowsable] attributes:

[Browsable(false)]
[EditorBrowsable(EditorBrowsableState.Never)]
public class PublicButHiddenType
{
}

These types won't show up in Intellisense, so for most purposes, they're not public. If you wanted to exclude them from the generated public API, you could do it something like this:

[Fact]
public Task PublicApiHasNotChanged()
{
    var assembly = typeof(HeaderPolicyCollection).Assembly;
    // get all the types in the assembly, and filter out the hidden items
    var types = assembly.GetTypes();
                    .Where(t => IsVisibleToIntelliSense(t))
                    .ToArray();

    var options = new ApiGeneratorOptions
    {
        // These attributes won't be included in the public API
        ExcludeAttributes =
        [
            typeof(InternalsVisibleToAttribute).FullName,
            "System.Runtime.CompilerServices.IsByRefLike",
            typeof(TargetFrameworkAttribute).FullName,
        ],
        // Only use these types, but apply filtering from the other
        // options
        IncludeTypes = types
    };

    var publicApi = assembly.GeneratePublicApi(options);

    return Verifier.Verify(publicApi);
}

private static bool IsVisibleToIntelliSense(Type type)
{
    var browsable = type.GetCustomAttribute<BrowsableAttribute>();
    if (browsable is null || browsable.Browsable)
    {
        // It doesn't have the browsable attribute, or it is Browsable == true
        return true;
    }

    var editorBrowsable = type.GetCustomAttribute<EditorBrowsableAttribute>();
    if (editorBrowsable is null || editorBrowsable.State != EditorBrowsableState.Never)
    {
        // It doesn't have the browsable attribute, or it has a visible state
        return true;
    }

    // The type won't be visible to consumers
    return false;
}

Note that when you set IncludeTypes as above, it defines the initial set of types to consider. Further types may be excluded from this list depending on other options, such as ExcludeAttributes and DenyNamespacePrefixes.

And there you have it, two ways to keep track of your public API: you can use Microsoft's analyzer; or you could use my preferred approach, PublicApiGenerator.

Summary

In this post I showed how Microsoft documents their public API using the Microsoft.CodeAnalysis.PublicApiAnalyzers package, recording the APIs in PublicApi.Shipped.txt and PublicApi.Unshipped.txt files. I then discussed some of the reasons I'm not a big fan of it for small projects.

Next I described an approach I prefer, using an open source project called PublicApiGenerator to document the public API, and using Verify to create a snapshot test to keep track of when your public API changes. I showed how to create the test, and some of the options you can use to customise what's documented.

Neither of these approaches will prevent breaking changes to your API, but they both provide a way for you to document and track what your public API is, which is the first step to avoiding changing it accidentally.


Viewing all articles
Browse latest Browse all 743

Trending Articles