Skip to content

Commit

Permalink
Source generator for converting WebAPIService classes to MediatRed Mi…
Browse files Browse the repository at this point in the history
…nimal API registrations
  • Loading branch information
jezzsantos committed Sep 15, 2023
1 parent 31c0495 commit fad5c64
Show file tree
Hide file tree
Showing 44 changed files with 1,251 additions and 6 deletions.
46 changes: 46 additions & 0 deletions docs/decisions/0050-api-framework.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# API Framework

* status: proposed

* date: 2023-09-13
* deciders: jezzsantos

# Context and Problem Statement

When it comes to writing REST web APIs in the template, we want to establish scalable patterns that focus on the API's requests and responses and the cross-cutting concerns involved:

* Routes and verbs
* Authenticated or not
* Authorized or not, by roles with (RBAC)
* Authorized or not, by feature set
* Request Validation
* Rate limiting
* Response Caching
* Response types (e.g. JSON or Stream)
* Mapping etc
* Exception Handling
* etc...

We know that in this architecture, the web API will mostly be delegating to an in-proc Application layer, and so we know that there will not be much code in this layer except to identify the Application Layer call.

We also know that the requests and responses need to be easily referenced by clients and tests.

Thus, we need a simple, structured, and consistent way to define request and response types and decorate them with various attributes.

## Considered Options

The traditional .NET options include:

1. ASP.NET Minimal APIs ([MediatR](https://github.com/jbogard/MediatR)'ed)
2. ASP.NET Minimal APIs (out of the box)
3. ASP.NET Controllers
4. Web frameworks like: ServiceStack

## Decision Outcome

`MediatR'ed Minimal APIs`

- ASP.NET controllers were never a great abstraction to represent REST APIs. They were a poor adaptation of the ASP.NET MVC implementation re-purposed for sending JSON, and long overdue for a redesign. Until recently (< .net 6.0) MVC Controllers were the only choice from the ASP.NET team.
- ASP.NET Minimal APIs (out of the box > .net 6.0) are simple and easy to define for demonstrating example code, but for use in larger systems are very awkward to organize, maintain, test, and reuse, as they are today.
- ServiceStack is an ideal web framework for structuring and handling complex web APIs. It has resolved many of the design challenges that ASP.NET controllers has suffered from. It implements the [REPR design pattern](https://deviq.com/design-patterns/repr-design-pattern) very well indeed, and is a delight to use, in many aspects. However, the framework today has a huge surface area, which we are not interested in leveraging most of. It is not so well known to the wider developer community, in part because it is also licensed per developer for a significant annual fee. This last point disqualifies it for use in this template.
- ASP.NET Minimal API's that are MediatR'ed and that can remove some of the redundancy and tedium of the current MediatR patterns (i.e. one ctor per handler, and duplicate ctor for api collections) can bring a more usable, structured, and testable way to define minimal APIs that is close to the same feel of defining and using ServiceStack API's, that does not change them functionally, but does offer more maintainable ways to define and reuse them across a whole system
6 changes: 3 additions & 3 deletions docs/design-principles/0010-rest-api.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# REST API Design
# REST API Design

Q. How is our web API to be designed?

Expand All @@ -24,9 +24,9 @@ Level 3 of the [Richardson Maturity Model](http://restcookbook.com/Miscellaneous

Even though most web APIs are defined by the HTTP verbs: `POST`, `GET`, `PUT`, `PATCH`, `DELETE` (and others),

- AND these verbs *could be* conveniently translated nicely into `Create` `Retrieve`, `Update` and `Delete` (CRUD) functions of a database.
- AND these verbs *could be* conveniently translated nicely into `Create` `Retrieve`, `Update` and `Delete` (CRUD) functions of a database.

- AND given that REST is designed around a "Resource", each with an identifier.
- AND given that REST is designed around a "Resource", each with an identifier.

Designing a REST API for web interop is not to be confused with designing a database API with CRUD.

Expand Down
53 changes: 53 additions & 0 deletions docs/design-principles/0020-api-framework.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Web Framework

## Design Drivers

1. We want to leverage core supported Microsoft ASP.NET facilities, rather than some other bespoke framework (like ServiceStack.net).
2. We are choosing Minimal API's over Controllers.
3. We want to model Requests and Responses that are related, easy to validate and in one place the code, We desire the [REPR design pattern](https://deviq.com/design-patterns/repr-design-pattern).
4. However, the standard Minimal API patterns are difficult to organize and maintain in larger codebases.
5. We want a design that is easier to define and organize the API into modules, but yet have them be realised as Minimal APIs.
6. We want to have automatic validation, as whole requests
7. We want to [MediatR](https://github.com/jbogard/MediatR) handlers, to make Minimal API registration and dependency injection easier

### Modularity

One of the distinguishing design principles of a Modular Monolith over a Monolith is the ability to deploy any, all or some the API's in any number of deployment units. Taken to the extreme, you would end up with granular microservices. But smaller steps are very acceptable depending on the stage of the SaaS product.

The ability to deploy any (Subdomain) of the code to a separate web host, should be quick and easy to accomplish.

One of the things that has to be easy to do, is to register who the endpoints of a subdomain in whatever host you like, as well as all its dependencies.

With minimal API's there should be a modular way of registering both its endpoints and handlers, and then moving them to other hosts later.

### Organisation

The design of Minimal API's makes developing 10s or 100s of them in a single project quite unwieldy. They certainly would not live in one file.

Since they are registered as handlers, there is no concept of groups of API's. Whereas many API endpoints are naturally grouped or categorized. This is certainly the case when exposing subdomains.

When using MediatR to register handlers for minimal API's, and with dependency injection, it becomes quite tedious and repetitive to write a handler class for every route, when many routes are grouped and will be sharing the same dependencies.

There are better ways to organize these groups of endpoints into classes, and test them more easily.

### Validation

When you design endpoints you want the requests and responses to be coupled, and you want the requests to be validated automatically when requests come in. Writing wiring code for validation is lso very tedious and error prone, and so is writing code to response with errors in a consistent manner.

We want the codebase to make validation easier to do, and apply it automatically and have standard ways to report errors detected by it.

## Configuring API's

All APIs will be defined in a separate project that is initially part of a subdomain group of code. That project can then be registered as a module into a specific web host, and with it all the endpoints, handlers, dependencies needed for all layers of the subdomain.

The web host, will then code generate the endpoint declarations and handlers and register them with Minimal API, and other components can be registered with the IoC.

### Reference the Source Generator

Every API project must reference the Source Generators in `Infrastructure.WebApi.Generators`.

EveryAPI must provide a plugin.

The plugin will then automatically call the source-generated registration code, update the runtime configuration of the web host and populate the IoC automatically.

The configuration of the web host and its features will be encapsulated and provided by various extension methods, so that all API hosts are consistent.
6 changes: 6 additions & 0 deletions src/.run/AllHosts.run.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="AllHosts" type="CompoundRunConfigurationType">
<toRun name="ApiHost1: ApiHost1-Development" type="LaunchSettings" />
<method v="2" />
</configuration>
</component>
19 changes: 19 additions & 0 deletions src/ApiHost1/ApiHost1.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\CarsApi\CarsApi.csproj" />
<ProjectReference Include="..\Infrastructure.WebApi.Common\Infrastructure.WebApi.Common.csproj" />
<ProjectReference Include="..\Infrastructure.WebApi.Interfaces\Infrastructure.WebApi.Interfaces.csproj" />
</ItemGroup>

<!-- Runs the source generator (in memory) on build -->
<ItemGroup>
<ProjectReference Include="..\Infrastructure.WebApi.Generators\Infrastructure.WebApi.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
38 changes: 38 additions & 0 deletions src/ApiHost1/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using CarsApplication;
using Infrastructure.WebApi.Common;
#if TESTINGONLY
using Infrastructure.WebApi.Interfaces.Operations.TestingOnly;
#endif

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMediatR(configuration =>
{
//TODO: we will have make sure that we have the assemblies of all APIs here somehow
configuration.RegisterServicesFromAssembly(typeof(Program).Assembly);
});
builder.Services.AddScoped<ICarsApplication, CarsApplication.CarsApplication>();

var app = builder.Build();

// app.MapGet("/cars/{id}",
// ([AsParameters] GetCarRequest request) =>
// Results.Ok(new GetCarResponse { Message = $"Hello car {request.Id}!" }));

//TODO: Need to build these registrations at startup by examining classes
#if TESTINGONLY
app.MediateGet<GetTestingOnlyRequest, GetTestingOnlyResponse>("/testingonly/{id}");
#endif

//app.RegisterRoutes();

//TODO: need to combine with validation
//TODO: need to add swaggerUI
//TODO: need ot register modules

app.Run();


public partial class Program
{
}
36 changes: 36 additions & 0 deletions src/ApiHost1/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"profiles": {
"ApiHost1-Development": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"ApiHost1-CI": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "CI"
}
},
"ApiHost1-Production": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://api.saastack.io",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Production"
}
},
"SourceGenerator-Development": {
"commandName": "DebugRoslynComponent",
"targetProject": "../ApiHost1/ApiHost1.csproj"
}
}
}

16 changes: 16 additions & 0 deletions src/ApiHost1/Services/TestingOnly/TestingOnlyApi.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#if TESTINGONLY
using Infrastructure.WebApi.Interfaces;
using Infrastructure.WebApi.Interfaces.Operations.TestingOnly;

namespace ApiHost1.Services.TestingOnly;

public class TestingOnlyApi : IWebApiService
{
[WebApiRoute("/testingonly/{id}", WebApiOperation.Get)]
public async Task<IResult> Get(GetTestingOnlyRequest request, CancellationToken cancellationToken)
{
await Task.CompletedTask;
return Results.Ok(new GetTestingOnlyResponse { Message = "amessage" });
}
}
#endif
8 changes: 8 additions & 0 deletions src/ApiHost1/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions src/ApiHost1/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
7 changes: 7 additions & 0 deletions src/Application.Interfaces/Application.Interfaces.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>

</Project>
5 changes: 5 additions & 0 deletions src/Application.Interfaces/ICallerContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Application.Interfaces;

public interface ICallerContext
{
}
24 changes: 24 additions & 0 deletions src/CarsApi/CarsApi.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using CarsApplication;
using Infrastructure.WebApi.Common;
using Infrastructure.WebApi.Interfaces;
using Infrastructure.WebApi.Interfaces.Operations.Cars;
using Microsoft.AspNetCore.Http;

namespace CarsApi;

public class CarsApi : IWebApiService
{
private readonly ICarsApplication _carsApplication;

public CarsApi(ICarsApplication carsApplication)
{
_carsApplication = carsApplication;
}

[WebApiRoute("/cars/{id}", WebApiOperation.Get)]
public async Task<IResult> Get(GetCarRequest request, CancellationToken cancellationToken)
{
var car = await _carsApplication.GetCarAsync(new CallerContext(), request.Id, cancellationToken);
return Results.Ok(new GetCarResponse { Car = car });
}
}
19 changes: 19 additions & 0 deletions src/CarsApi/CarsApi.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\CarsApplication\CarsApplication.csproj" />
<ProjectReference Include="..\Infrastructure.WebApi.Common\Infrastructure.WebApi.Common.csproj" />
<ProjectReference Include="..\Infrastructure.WebApi.Interfaces\Infrastructure.WebApi.Interfaces.csproj" />
</ItemGroup>

<!-- Runs the source generator (in memory) on build -->
<ItemGroup>
<ProjectReference Include="..\Infrastructure.WebApi.Generators\Infrastructure.WebApi.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
12 changes: 12 additions & 0 deletions src/CarsApplication/CarsApplication.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Application.Interfaces;

namespace CarsApplication;

public class CarsApplication : ICarsApplication
{
public async Task<string> GetCarAsync(ICallerContext caller, string? id, CancellationToken cancellationToken)
{
await Task.CompletedTask;
return $"Hello car {id}!";
}
}
11 changes: 11 additions & 0 deletions src/CarsApplication/CarsApplication.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Application.Interfaces\Application.Interfaces.csproj" />
</ItemGroup>

</Project>
8 changes: 8 additions & 0 deletions src/CarsApplication/ICarsApplication.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Application.Interfaces;

namespace CarsApplication;

public interface ICarsApplication
{
Task<string> GetCarAsync(ICallerContext caller, string? id, CancellationToken cancellationToken);
}
2 changes: 1 addition & 1 deletion src/Common.UnitTests/Common.UnitTests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/Common/Common.csproj
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
</Project>
5 changes: 5 additions & 0 deletions src/Common/Infrastructure.Api.Interfaces/Class1.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Common.Infrastructure.Api.Interfaces;

public class Class1
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
Loading

0 comments on commit fad5c64

Please sign in to comment.