Mastering External Web API's in ASP.Net Core and ABP with Swagger, ApiExplorer, and NSwag
Recently a customer asked me to build out a small end-user facing web API in addition to the existing one used by my SPA (Angular) app. A few weeks later someone asked me how to do this on my YouTube channel.
Excellent video!!! I have the same project and I am trying to add a second webapi to be used in a couple of pages, but I don't know where to start. Any example? Thanks
Alejandro Souza
This seemed like a great opportunity to blog about my experience and share the knowledge of my approach and solution with a wider audience. I also recorded this as an episode of Code Hour if you're more of a visual learner.
My current application is built on ASP.Net Boilerplate with the Angular template. While that isn't strictly important to this story, what is, is that it's an ASP.Net Core app with where Swashbuckle (a tool to "Generate beautiful API documentation") generates a Swagger document.
I initially considered adding an additional micro service to the Kubernetes cluster that my site is deployed in. The problem was that the new API was small, and the amount of work involved in setting up security, DI, logging, app settings, configuration, docker, and Kubernetes port routing seemed excessive.
I wanted a lighter weight alternative that extended my existing security model and kept my existing configuration. Something like this:
More Cowbell Swagger
Adding a second swagger file to my existing web app was relatively easy. Controlling what was in it, less so.
To add that second swagger file I just had to call .SwaggerDoc a second time in services.AddSwaggerGen in Startup.cs
services.AddSwaggerGen(options =>
{
// add two swagger files, one for the web app and one for clients
options.SwaggerDoc("v1", new OpenApiInfo()
{
Title = "LeesStore API",
Version = "v1"
});
options.SwaggerDoc("client-v1", new OpenApiInfo
{
Title = "LeesStore Client API",
Version = "client-v1"
});
Technically, this is saying that I have two versions of the same API, rather than two separate API's, but the effect is the same. The 1st swagger file is exposed at http://localhost/swagger/v1/swagger.json, and the second one is exposed at http://localhost/swagger/client-v1/swagger.json.
That's a start. If you love the Swagger UI that Swashbuckle provides as much as I do, you'll agree it's worth trying to add both swagger files to it. That turned out to be easy with a second call to .SwaggerEndpoint in the UseSwaggerUI call in Startup.cs:
app.UseSwaggerUI(options =>
{
var baseUrl = _appConfiguration["App:ServerRootAddress"]
.EnsureEndsWith('/');
options.SwaggerEndpoint(
$"{baseUrl}swagger/v1/swagger.json",
"LeesStore API V1");
options.SwaggerEndpoint(
$"{baseUrl}swagger/client-v1/swagger.json",
"LeesStore Client API V1");
Now I could choose between the two swagger files in the "Select a definition" dropdown in the top right:
That's pretty nice, right?
Except: both pages look identical. That's because all methods are currently included in both definitions.
Exploring the ApiExplorer
To solve that, I needed to dig a little into how Swashbuckle works. It turns out that internally it uses ApiExplorer, an API metadata layer that ships with ASP.Net Core. And in particular, it uses the ApiDescription.GroupName property to determine which methods to put in which files. If the property is null or it's equal to the document name (e.g. "client-v1"), then Swashbuckle includes it. And, it's null by default, which is why both Swagger files are identical.
There are two ways to set GroupName. I could have set it by setting the ApiExplorerSettings attribute on every single method of my controllers, but that would have been tedious and hard to maintain. Instead, I chose the magical route.
That involves registering an action convention in Startup.cs
services.AddControllersWithViews(
options => {
options.Conventions.Add(new SwaggerFileMapperConvention());
and assigning actions to documents based on namespaces, like this:
public class SwaggerFileMapperConvention : IControllerModelConvention
{
public void Apply(ControllerModel controller)
{
var controllerNamespace = controller?.ControllerType?.Namespace;
if (controllerNamespace == null) return;
var namespaceElements = controllerNamespace.Split('.');
var nextToLastNamespace = namespaceElements.ElementAtOrDefault(namespaceElements.Length - 2)?.ToLowerInvariant();
var isInClientNamespace = nextToLastNamespace == "client";
controller.ApiExplorer.GroupName = isInClientNamespace ? "client-v1" : "v1";
}
}
If you run that you'll see that everything is still duplicated. That's because of this sneaky line in Startup.cs
services.AddSwaggerGen(options => {
options.DocInclusionPredicate((docName, description) => true);
The DocInclusionPredicate wins when there's a conflict. If we take that out then, well, Radiohead says it best:
Consuming the Swagger
In case you've somehow missed it, I'm a big fan of Cake. It's a dependency management tool (like Make, Rake, Maven, Grunt, or Gulp) that allows writing scripts in C#. It contains a plugin for NSwag, which is one of several tools for auto-generating proxies from swagger files. I thus generated a proxy like this:
#addin nuget:?package=Cake.CodeGen.NSwag&version=1.2.0&loaddependencies=true
…
Task("CreateProxy")
.Description("Uses nswag to re-generate a c# proxy to the client api.")
.Does(() =>
{
var filePath = DownloadFile("http://localhost:21021/swagger/client-v1/swagger.json");
Information("client swagger file downloaded to: " + filePath);
var proxyClass = "ClientApiProxy";
var proxyNamespace = "LeesStore.Cmd.ClientProxy";
var destinationFile = File("./aspnet-core/src/LeesStore.Cmd/ClientProxy/ClientApiProxy.cs");
var settings = new CSharpClientGeneratorSettings
{
ClassName = proxyClass,
CSharpGeneratorSettings =
{
Namespace = proxyNamespace
}
};
NSwag.FromJsonSpecification(filePath)
.GenerateCSharpClient(destinationFile, settings);
});
Ran it with build.ps1 -target CreateProxy
or build.sh -target CreateProxy
on Mac/linux, and out popped a strongly typed ClientApiProxy class that I could consume in a console like this:
using var httpClient = new HttpClient();
var clientApiProxy = new ClientApiProxy("http://localhost:21021/", httpClient);
var product = await clientApiProxy.ProductAsync(productId);
Console.WriteLine($"Your product is: '{product.Name}'");
?? ... Not So Fast
Happy ending, everyone wins right? Not quite. If you're running in ASP.Net Boilerplate that always returns Your product is ""
. Why? The quiet failure was tricky to track down. Watching site traffic in Fiddler I saw this:
{"result":{"name":"The Product","quantity":0,"id":2},"targetUrl":null,"success":true,"error":null,"unAuthorizedRequest":false,"__abp":true}
That seems reasonable at first glance. However, that won't deserialize into a ProductDto because the ProductDto in the JSON is inside a "result" object. The wrapping feature is how (among other things) ABP returns UserFriendlyException messages to the user in nice modal dialogs.
The above screenshot came from JSON like this:
{"result":null,"targetUrl":null,"success":false,"error":{"code":0,"message":"Dude, an exception just occurred, maybe you should check on that","details":null,"validationErrors":null},"unAuthorizedRequest":false,"__abp":true}
The solution turned out to be pretty easy. Putting a DontWrapResult attribute onto the controller:
[DontWrapResult(WrapOnError = false, WrapOnSuccess = false, LogError = true)]
public class ProductController : LeesStoreControllerBase
Resulted in nice clean JSON
{"name":"The Product","quantity":0,"id":2}
And the console app writing Your product is "The Product"
.
Fantastic.
Final Tips and Tricks
One last thing. That method name "ProductAsync" seems a bit unfortunate. Where did it even come from?
Turns out when I wrote this:
[HttpGet("api/client/v1/product/{id}")]
public async Task<ProductDto> GetProduct(int id)
The ApiExplorer only exposed the endpoint, not the method name. Thus Swashbuckle didn't include an operationId in the Swagger file and NSwag was forced to use elements in the endpoint to come up with a name.
The fix is to specify the name so Swashbuckle can generate an operationId. That's easy with the Name property in the HttpGet or HttpPost attribute. And thanks to nameof in C# 6 we can keep it strongly typed.
[HttpGet("api/client/v1/product/{id}", Name = nameof(GetProduct))]
public async Task<ProductDto> GetProduct(int id)
And that generates the await clientApiProxy.GetProductAsync(productId);
I would expect.
Conclusion
This post is the story of how to generate an unauthenticated client. Check back soon for a follow-up on how to generate API Keys to perform authentication and authorization on an external Web API.
In the meantime, all the code is runnable in the multiple-api's branch or perusable in the Multiple API's Pull Request of the LeesStore demo site. I hope this is helpful. If so, let me know on Twitter at @lprichar.