Click here to Skip to main content
14,578,910 members

Mastering External Web APIs in ASP.NET Core and ABP with Swagger, ApiExplorer, and NSwag

Rate this:
4.91 (3 votes)
Please Sign up or sign in to vote.
4.91 (3 votes)
1 Jun 2020CPOL
This post is the story of how to generate an unauthenticated client.
This blog entry goes through adding a second swagger file to my existing web app, and controlling what is in it.

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:

Image 1

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 APIs, but the effect is the same. The first 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:

Image 2

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 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.

Image 3

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)</productdto>

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 @lprichar or in the comments.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Lee P Richardson
Web Developer
United States United States
Lee is the author of Siren of Shame, a USB siren currently monitoring continuous integration builds in over 300 companies in 28 countries across the world.

News sites including CodeProject, Visual Studio Magazine, and DevX.com have published nearly two dozen of Lee's technical articles since 2006. He is an avid blogger at leerichardson.com with more than 75 posts over the last decade.

He has worked in software development in the Washington, DC Metropolitan Area for close to 20 years and is currently a senior developer at InfernoRed where he is building cross platform iOS and Android applications for the banking industry.

Comments and Discussions

 
-- There are no messages in this forum --
Technical Blog
Posted 1 Jun 2020

Stats

2K views
7 bookmarked