Ever since the c# source generators were announced, I was wondering what are real-world examples where one would reasonably use them. I am working on web applications, but I do not seek performance gains on a gigantic planet-level scale. This crosses out most performance use-cases for me. What I do write at work is web APIs, sometimes facing other services. That is the direction I want to explore.
I realised that a task that I want to automate is generating an API Client facade for my web applications. They are often used for other C# clients which call HTTP services that I develop. Additionally, such a client can be used by myself in the same solution, where I write API tests.
The working implementation can be found at GitHub repository ApiClientGenerator.
How was it implemented? How about we dive in?
API Scenario
Let's pick a domain for an arbitrary API - a cup shop. "What? A cup shop?", - you may ask. Yes. I like cups.
For presentation purposes, imagine a very simple API for such a shop. Well, we need to add cups, get cups and delete cups. To make things simple, we say that cups cannot be updated (How do you change a cup once it's made?).
OK, we start with bootstrapping an application from the command line
dotnet new api -n CupsApiSourceGenerator
Now we have an API project. What properties should our cups have? I want them to have a name. Also, I don't want people to use tea cups to drink coffee, hence we define what we expect customers to pour into their cups.
public enum CupPreferredLiquid { Tea, Coffee } public record Cup(int Id, string Name, CupPreferredLiquid PreferredLiquid);
Records, anyone? I love records - they allow me to express data most succinctly. Anyways, a simple in-memory API implementaion for such entities:
[ApiController] [Route("cups")] public class CupsController : ControllerBase { private static readonly List<Cup> _cups = new(); private static int _maxId = 1; [HttpGet] public ActionResult<Cup[]> GetAllCups() { return _cups.ToArray(); } [HttpDelete("{id}")] public IActionResult DeleteCup(int id) { var cup = _cups.FirstOrDefault(c => c.Id == id); if (cup != null) { _cups.Remove(cup); } return NoContent(); } [HttpPost] public ActionResult<Cup> CreateCup(CreateCupRequest request) { var nextId = _maxId++; var cup = new Cup(nextId, request.Name, request.PreferredLiquid); _cups.Add(cup); return cup; } }
Here, we have route parameter and a body parameter, GET, POST and a DELETE request. We will continue with generating a Client for this API.
Source Generator - Scanning
Source generators API provide you with Roslyn constructs that represent information about current Compilation and a way to append source files to such Compilation. Well, I am not very familiar with Roslyn API, so I will learn along the way.
We need to create the project for the generator, following the official announcement page.
Then, let's define the data structure which the scanner will fill from the available ASP.NET Core code. The most important things for an API client are routes, so they will be central to our implementation. In this section, I will only consider routing via controller convention. So, we have Controller routes, which are composed of Action routes.
public record Parameter(string FullTypeName); public record ParameterMapping(string Key, Parameter Parameter); public record ActionRoute(string Name, HttpMethod Method, string Route, string? ReturnTypeName, ParameterMapping[] Mapping, ParameterMapping? Body); public record ControllerRoute(string Name, string BaseRoute, ActionRoute[] Actions);
Our next step is to somehow identify all controllers in our compilation. For this, I decided to find all inheritors of ControllerBase:
public static IEnumerable<ControllerRoute> ScanForControllers(SemanticModel semantic) { var controllerBase = semantic.Compilation .GetTypeByMetadataName("Microsoft.AspNetCore.Mvc.ControllerBase"); if (controllerBase == null) { yield break; } var allNodes = semantic.SyntaxTree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>(); foreach (var node in allNodes) { var classSymbol = semantic.GetDeclaredSymbol(node) as INamedTypeSymbol; if (classSymbol != null && InheritsFrom(classSymbol, controllerBase)) { yield return ToRoute(classSymbol); } } }
Ok, that was not that hard. Since controllers are pretty useless on their own, let's find the actions - the items that contain the principal pieces of information that are useful for us: the route, query parameters, HTTP method and body parameters (if any). Knowing that those actions are public methods and constructor is not an action, we can find them in the following way:
private static IEnumerable<ActionRoute> ScanForActionMethods(INamedTypeSymbol classSymbol) { foreach (var member in classSymbol.GetMembers()) { if (member is IMethodSymbol { DeclaredAccessibility: Accessibility.Public, IsAbstract: false } symbol and not { MethodKind: MethodKind.Constructor }) { ...
Having found the actions, we need to find the properties which interest us. Firstly, the response.
From convention, such a response can be wrapped in Task<T>
, ActionResult<T>
, or even Task<ActionResult<T>>
.
Unwrapping the generic parameter will help us to find the actual result type:
var returnType = symbol.ReturnType; // Unwrap Task<T> if (returnType is INamedTypeSymbol taskType && taskType.OriginalDefinition.ToString() == "System.Threading.Tasks.Task<TResult>") { returnType = taskType.TypeArguments.First(); } // Take unwrapped T and check whether we need to // unwrap further to V when T = ActionResult<V> if (returnType is INamedTypeSymbol actionResultType && actionResultType.OriginalDefinition.ToString() == "Microsoft.AspNetCore.Mvc.ActionResult<TValue>") { returnType = actionResultType.TypeArguments.First(); }
Additionally, let's handle the case when the action returns nothing:
// If the return type is simple IActionResult -- assume that the return type is essentially void if (returnType.OriginalDefinition.ToString() == "Microsoft.AspNetCore.Mvc.IActionResult") { returnType = null; }
How do we find the route of the action? That is pretty straightforward when using attribute routing.
Our data is represented as the constructor argument in attributes like [HttpPut("route/subroute")]
.
var attribute = FindAttribute(symbol, a => a.BaseType?.ToString() == "Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute"); var route = attribute?.ConstructorArguments.FirstOrDefault().Value?.ToString() ?? string.Empty;
Our action arguments are essentially the action method arguments, so let's copy them
var parameters = symbol.Parameters .Select(p => new ParameterMapping(p.Name, new Parameter(p.Type.ToString()))) .ToArray();
However, we need to determine which parameter is the body of the request. If we assume that the controller is an ApiController, then ASP.NET Core finds a complex object within parameters and binds it to the request body. I decided to define a complex object as a non-primitive one, therefore:
var bodyParameter = symbol.Parameters.Where(t => t.Type != null && !IsPrimitive(t.Type)) .Select(t => new ParameterMapping(t.Name, new Parameter(t.Type.ToString()))) .FirstOrDefault();
Source Generator - Materialisation
Now we have our actions, we just need to generate an API Client in the correct format.
Our API client needs a HttpClient to make requests. Other from that, I decided to create a separate object for each controller because I don't like polluting a class with many public methods.
... source.AppendLine("private readonly HttpClient _client;"); // Ctor initialization source.AppendLine("_client = client;"); foreach (var route in routes) { source.AppendLine($"{route.Name} = new {route.Name}(client);"); } ... // Properties foreach (var route in routes) { source.AppendLine(); source.AppendLine($"public {route.Name} {route.Name} {{ get; }}"); } ...
Now as we've got our ApiClient class defined, the next ones are the APIs which are defined by each controller route.
Each Action corresponds to a specific method in our controller API.
The method return type is either Task
or Task<T>
depending on whether the API has a response body.
var returnType = action.ReturnTypeName != null ? $"Task<{action.ReturnTypeName}>" : "Task";
All parameters of the action are parameters of our action method.
var parameterList = string.Join(", ", action.Mapping.Select(m => $"{m.Parameter.FullTypeName} {m.Key}"));
The actual route is a combined route of the controller and action method.
var routeValue = Path.Combine(route.BaseRoute, action.Route).Replace("\\", "/");
Another thing we need to decide is how and when to deserialize request and response. Our decision will be based on whether we have found the appropriate request and response during the scanning phase.
var callStatement = action.Body switch { { Key: var key } => $"await _client.{methodString}AsJsonAsync({routeString}, {key});", _ => $"await _client.{methodString}Async({routeString});" }; if(action.ReturnTypeName == null) { source.AppendLine(callStatement); } else { source.AppendLine($"var result = {callStatement}"); source.AppendLine($"return await result.Content.ReadFromJsonAsync<{action.ReturnTypeName}>();"); }
And we're done! Our generator created the following source file:
using System.Net.Http; using System.Threading.Tasks; using System.Net.Http.Json; public class ApiClient { private readonly HttpClient _client; public ApiClient(HttpClient client) { _client = client; Cups = new Cups(client); } public Cups Cups { get; } } public class Cups { private readonly HttpClient _client; public Cups(HttpClient client) { _client = client; } public async Task<CupsApiSourceGenerator.Controllers.Cup[]> GetAllCups() { var result = await _client.GetAsync($"cups"); return await result.Content.ReadFromJsonAsync<CupsApiSourceGenerator.Controllers.Cup[]>(); } public async Task DeleteCup(int id) { await _client.DeleteAsync($"cups/{id}"); } public async Task<CupsApiSourceGenerator.Controllers.Cup> CreateCup( CupsApiSourceGenerator.Controllers.CreateCupRequest request) { var result = await _client.PostAsJsonAsync($"cups", request); return await result.Content.ReadFromJsonAsync<CupsApiSourceGenerator.Controllers.Cup>(); } }
ApiClient - Cups Tests
Let's try the generated Client class in Cups API. The easiest way to do that is to write tests for our API. We will verify that 3 of our API methods work correctly, using the ASP.NET Core Integration Testing suite. The API Client will help by providing strictly typed methods to invoke controller actions.
public class CupApiTests : IClassFixture<WebApplicationFactory<Startup>> { private readonly WebApplicationFactory<Startup> _factory; private readonly ApiClient _apiClient; public CupApiTests(WebApplicationFactory<Startup> factory) { _factory = factory; _apiClient = new ApiClient(_factory.CreateClient()); } [Fact] public async Task CanGetCreatedCup() { var request = new Controllers.CreateCupRequest( "MyLovelyCreated_ForGetCup", Controllers.CupPreferredLiquid.Coffee); await _apiClient.Cups.CreateCup(request); var allCups = await _apiClient.Cups.GetAllCups(); var cup = Assert.Single(allCups, cup => cup.Name == "MyLovelyCreated_ForGetCup"); Assert.Equal(Controllers.CupPreferredLiquid.Coffee, cup.PreferredLiquid); } [Fact] public async Task CanCreateCup() { var request = new Controllers.CreateCupRequest( "MyLovelyCreatedCup", Controllers.CupPreferredLiquid.Tea); var cup = await _apiClient.Cups.CreateCup(request); Assert.Equal("MyLovelyCreatedCup", cup.Name); Assert.Equal(Controllers.CupPreferredLiquid.Tea, cup.PreferredLiquid); } [Fact] public async Task CanDeleteCup() { var request = new Controllers.CreateCupRequest( "MyLovelyCreated_ForDeleteCup", Controllers.CupPreferredLiquid.Coffee); var cup = await _apiClient.Cups.CreateCup(request); await _apiClient.Cups.DeleteCup(cup.Id); var allCups = await _apiClient.Cups.GetAllCups(); Assert.DoesNotContain(allCups, c => c.Name == "MyLovelyCreated_ForDeleteCup"); } }
And the tests pass! The full code of everything described here can be found at GitHub ApiClientGenerator. The Cups example is also there at github ApiClientGenerator/examples.
Closing remarks
The biggest pain during development is that Visual Studio preview does not yet fully support source generators. You must restart the Visual Studio so that it can pick up the latest generated code. Also, debugging is hard. However, writing tests for the generator may help with the experience of developing one.
We have succeeded in writing an API Client generator. It is not fully-featured, though. We do not support query parameters and our scanning logic is based on lots of assumptions. Although the generator does not support everything, this proof-of-concept implementation showcased some of the possible applications of source generators. This approach can be enhanced to generate clients to support clients in NuGet packages, production tests and other possible use-cases.