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:
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:
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
orSystem
namespaces, but early in ASP.NET Core's lifetime a convention emerged of putting extensions onIServiceCollection
orIApplicationBuilder
, 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.