DEV Community

Cover image for Building REST APIs In .NET 8 The Easy Way!
Dĵ ΝιΓΞΗΛψΚ
Dĵ ΝιΓΞΗΛψΚ

Posted on • Updated on

Building REST APIs In .NET 8 The Easy Way!

The goal of this article is to introduce you to an alternative, more developer friendly way of building Web APIs with ASP.NET 8 instead of the more commonly used MVC controllers.

We will be exploring the open source library FastEndpoints which is built on top of Minimal APIs introduced in .NET 6, where we can get all the performance benefits without the pain-points of Minimal APIs. The resulting code-base is cleaner to navigate and project maintainability becomes a breeze when combined with Vertical Slice Architecture, no matter the size or complexity of the project, because the framework stays out of your way so you can focus on the engineering & functional aspects of your systems.

Let's get our hands dirty by building a REST API for a simplified version of Dev.to where authors/posters can sign up for an account and publish articles. New articles will go into a moderation queue where the site administrator would have to approve it before being publicly available.

The main entities in our system would be the following:

  • Admin
  • Author
  • Article

The features/user stories of the system could be classified as follows:

  • Admin
    • Login to site
    • Get a list articles to be moderated
    • Approve an article to publish it
    • Reject an article with a reason
  • Author
    • Sign up on site
    • Login to site
    • Get a list of their own articles
      • See status [pending/approved/rejected]
    • Create new article
    • Edit existing article
  • Public Area
    • Get a list of 50 latest articles
    • Get article by id
    • Get latest 50 comments for an article
    • Post a comment on an article

The tech stack used will be the following:

  • Base framework: ASP.NET 8
  • Endpoint framework: FastEndpoints
  • Authentication scheme: JWT Bearer
  • Input validation: FluentValidations
  • Data storage: MongoDB
  • API visualization: SwaggerUI

Let's go...

Create a new web project and install the dependencies either using visual studio or by running the following commands in a terminal window:

dotnet new web -n MiniDevTo
dotnet add package FastEndpoints
dotnet add package FastEndpoints.Swagger
dotnet add package MongoDB.Entities
Enter fullscreen mode Exit fullscreen mode

Create the folder structure for our features so that it looks like the following:

feature folder structure

Each last level of the tree is going to be a single endpoint which could either be a command or a query which the ui/frontend of our app can call. Queries are prefixed with Get as a convention indicating it is a retrieval of data, whereas commands are prefixed with verbs such as Save, Approve, Reject, etc. indicating committing of some state change. This might sound familiar if you've come across CQRS before, but we're not separating reads vs. writes here as done in CQRS. Instead, we're organizing our features/endpoints in accordance with Vertical Slice Architecture.

FastEndpoints is an implementation of REPR pattern. This will be the last pattern I'll talk about in this article, I promise!

The REPR Design Pattern defines web API endpoints as having three components: a Request, an Endpoint, and a Response. It simplifies the frequently-used MVC pattern and is more focused on API development.

So, in order to give us some relief from the boring, repetitive task of creating the multiple class files needed for an endpoint, go ahead and install either the Visual Studio or VS Code extension provided by FastEndpoints. Or you can just create those files manually.

Program.cs

First thing's first... Let's update Program.cs file to look like the following:

global using FastEndpoints;
global using FluentValidation;

var builder = WebApplication.CreateBuilder();
builder.Services.AddFastEndpoints();

var app = builder.Build();
app.UseFastEndpoints();
app.Run();
Enter fullscreen mode Exit fullscreen mode

This is all that's needed for this to be a web API project. but... If you try to run the program now, you will be greeted with a run-time exception such as the following:

InvalidOperationException: 'FastEndpoints was unable to find any endpoint declarations!'

Let's fix that by creating our very first endpoint using the VS Extension.

Author Signup Endpoint

right-click the Author/Signup folder in visual studio > add > new item. then select FastEndpoints Feature FileSet new item template located under the Installed > Visual C# node. then for the file name, enter Author.Signup.cs as shown below:

vs extension

What that will do is, it'll create a new set of files under the folder you selected with a namespace specific for this endpoint. open up Endpoint.cs file and have a look at the namespace at the top. It is what we typed in as the file name earlier.

While we have the endpoint class opened, go ahead and replace it's contents with the code below:

namespace Author.Signup;

public class Endpoint : Endpoint<Request, Response, Mapper>
{
    public override void Configure()
    {
        Post("/author/signup");
        AllowAnonymous();
    }

    public override async Task HandleAsync(Request r, CancellationToken c)
    {
        await SendAsync(new Response()
        {
            //blank for now
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

What do we have here? We have an endpoint class definition that inherits from the generic base class Endpoint<TRequest, TResponse, TMapper>. it has 2 overridden methods Configure() and HandleAsync().

In the configure method, we're specifying that we want the endpoint to listen for the http verb/method POST on the route /author/signup. We're also saying that unauthenticated users should be allowed to access this endpoint by using the AllowAnonymous() method.

The HandleAsync() method is where you'd write the logic for handling the incoming request. For now it's just sending a blank response because we haven't yet added any fields/properties to our request & response DTO classes.

Models.cs

Open up the Models.cs file and replace request and response classes with the following:

public class Request
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string UserName { get; set; }
    public string Password { get; set; }
}

public class Response
{
    public string Message { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Swagger UI

Now, let's setup Swagger so we have a way to interact with our endpoints using a web browser as opposed to using something like Postman. Open up Program.cs again and make it look like this:

global using FastEndpoints;
using FastEndpoints.Swagger; //add this

var builder = WebApplication.CreateBuilder();
builder.Services.AddFastEndpoints();
builder.Services.SwaggerDocument() //add this

var app = builder.Build();
app.UseFastEndpoints();
app.UseSwaggerGen(); //add this
app.Run();
Enter fullscreen mode Exit fullscreen mode

Then open up Properties/launchSettings.json file and replace contents with this:

{
  "profiles": {
    "MiniDevTo": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": false,
      "applicationUrl": "http://localhost:8080",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Updating launch-settings is not mandatory but let's fix the listening port of our API server to 8080 for the purpose of this article.

Next build and run your project in debug mode (CTRL+F5 in Visual Studio). Fire up your web browser and head on over to the url http://localhost:8080/swagger to see the Swagger UI.

You should now be seeing something like this:

Expand the /author/signup endpoint, and modify the request body/json to look like this (click Try It Out button to do so):

{
  "FirstName": "Johnny",
  "LastName": "Lawrence",
  "Email": "what@is.uber",
  "UserName": "EagleFang",
  "Password": "death2kobra"
}
Enter fullscreen mode Exit fullscreen mode

Before executing the request, head on over to the Endpoint.cs file and place a breakpoint on line 14. Then go ahead and hit the execute button in swagger. Once the breakpoint is hit, inspect the request DTO parameter of the HandleAsync() method where you will see something like this:

That is basically how you receive a request from a client (Swagger UI in this case). The handler method is supplied with a fully populated POCO from the incoming http request. For a detailed explanation of how this model binding works, please have a look at the documentation page here.

Let's return a response from our endpoint now. Stop debugging and update the HandleAsync() method to look like the following:

public override async Task HandleAsync(Request r, CancellationToken c)
{
    await SendAsync(new Response()
    {
        Message = $"hello {r.FirstName} {r.LastName}! your request has been received!"
    });
}
Enter fullscreen mode Exit fullscreen mode

Start the app again and execute the same request in Swagger UI. Which should display the response from the server as follows:

There are multiple ways to send responses back to the client from a handler. here we're sending a new instance of a response DTO populated with a custom message.

Input Validation

Open the Models.cs file and make the validator class look like the following:

public class Validator : Validator<Request>
{
    public Validator()
    {
        RuleFor(x => x.FirstName)
            .NotEmpty().WithMessage("your name is required!")
            .MinimumLength(3).WithMessage("name is too short!")
            .MaximumLength(25).WithMessage("name is too long!");

        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("email address is required!")
            .EmailAddress().WithMessage("the format of your email address is wrong!");

        RuleFor(x => x.UserName)
            .NotEmpty().WithMessage("a username is required!")
            .MinimumLength(3).WithMessage("username is too short!")
            .MaximumLength(15).WithMessage("username is too long!");

        RuleFor(x => x.Password)
            .NotEmpty().WithMessage("a password is required!")
            .MinimumLength(10).WithMessage("password is too short!")
            .MaximumLength(25).WithMessage("password is too long!");
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we're defining the input validation requirements using Fluent Validation rules. Let's see what happens when the user input doesn't meet the above criteria. Execute the same request in swagger with the following incorrect json content:

{
  "LastName": "Lawrence",
  "Email": "what is email?",
  "UserName": "EagleFang",
  "Password": "123"
}
Enter fullscreen mode Exit fullscreen mode

The server will respond with this:

{
  "StatusCode": 400,
  "Message": "One or more errors occured!",
  "Errors": {
    "FirstName": [ "your name is required!" ],
    "Email": [ "the format of your email address is wrong!" ],
    "Password": ["password is too short!" ]
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, if the incoming request data does not meet the validation criteria, a http 400 bad response is returned with the details of what is wrong. The handler logic will not be executed in case there is a validation error in the incoming request. This default behavior can be changed like this if need be.

Handler Logic

Let's go ahead and persist a new Author entity to the database by modifying the handler logic.

public override async Task HandleAsync(Request r, CancellationToken c)
{
    var author = Map.ToEntity(r);

    var emailIsTaken = await Data.EmailAddressIsTaken(author.Email);

    if (emailIsTaken)
        AddError(r => r.Email, "Sorry! Email address is already in use...");

    var userNameIsTaken = await Data.UserNameIsTaken(author.UserName);

    if (userNameIsTaken)
        AddError(r => r.UserName, "Sorry! Ehat username is not available...");

    ThrowIfAnyErrors();

    await Data.CreateNewAuthor(author);

    await SendAsync(new()
    {
        Message = "Thank you for signing up as an author!"
    });
}
Enter fullscreen mode Exit fullscreen mode

First, we're using the ToEntity() method on the Map property of the endpoint class to transform the request dto into an Author domain entity. The logic for mapping is in the Mapper.cs file which can be found here. you can read more about the mapper class here.

Then we're asking the database if this email address is already taken by someone (code here). If it's already taken we're adding a validation error to the collection of errors of the endpoint using the AddError() method.

Next, we're asking the db if the username is already taken by someone and add an error if it's taken.

After all the business rules are checked, we want to send an error response to the client if any of the previous business rule checks have failed. that's what the ThrowIfAnyErrors() does. When either the username or email address is taken, a response like the following will be sent to the client. Execution is stopped at that point and the proceeding lines of code are not executed.

{
  "StatusCode": 400,
  "Message": "One or more errors occured!",
  "Errors": {
    "Email": [ "sorry! email address is already in use." ],
    "UserName": [ "sorry! that username is not available." ]
  }
}
Enter fullscreen mode Exit fullscreen mode

If there are no validation errors added, and author creation worked, the following json response will be received by the client.

{
  "Message": "Thank you for signing up as an author!"
}
Enter fullscreen mode Exit fullscreen mode

Congratulations!

You've persevered thus far and have your first working endpoint. If you're interested in completing this exercise, head on over to github and have a look through the full source code. Things should now be self explanatory. If something is unclear, please comment here or open a github issue. I will try my best to answer within 24hrs. Also have a look through the following resources which will explain most of the code.

Resources

Latest comments (13)

Collapse
 
dadapipes profile image
dadaPipes

dotnet new web -n MiniDevTo
dotnet add package FastEndpoints
dotnet add package FastEndpoints.Swagger
dotnet add package MongoDB.Entities
dotnet add package BCrypt.Net-Next ( I needed this to make it work )

Collapse
 
alias111 profile image
Jose Martinez • Edited

This tutorial needs to be updated. Its due.
I got tired of going back and forth from this and the docs.
though this is a much deeper example then the docs i think
its time for an update that aligns with where the docs are at.
I see the use of a mapper in this article but in the docs its done differently with a brief example of how, if by any chance you create a mapper in this manner. Good thing the github is linked also but still needs an update. Hope its soon. Thanks for the article. Im not debating if its better then mvc or any of that. Im coming from js/ts with node/bun, so just the fact your using any api architecture for the folder structure is amazing. I also use brackets on the same line. So i know thats .Net blasphamy lol. I just need a solid update to this article or inbed this version of the tutorial in the docs. The current one is way simpler then this and this one seems the better of the two because of the detail. Just a suggestion.

Collapse
 
arizvitechgies profile image
arizvi-techgies

Nice! I was wondering if there is a way to separate the Features as a project.

For example:

MyDemoApp - Solution
MyDemoApp.API - CSPROJ (ASPNET Core Project)
MyDemoApp.Features.Admin - CSPROJ (Class Library)
MyDemoApp.Features.Client - CSPROJ (Class Library)

Each Feature project contains its own Endpoints and features folder structure, as shown in your blob.

Is there a way to bring all those Features Project .dll into MyDemoAPp.API and registered them in Startup.cs, so does Fast Endpoint pick them up?

Collapse
 
djnitehawk profile image
Dĵ ΝιΓΞΗΛψΚ

it's possible. you can add assemblies that contain endpoints for discovery in the .AddFastEndpoints(...) call. hit us up in our discord channel if you have trouble getting that sorted.

Collapse
 
tmac profile image
tmac

This is all very interesting, but what is the purpose that I would move into "FastEndpoints" vs. MediatR? Right now, ALL of our Controllers are 1 line of code each "action", meaning, there is absolutely no need to UnitTest a Controller, and the only thing the controller is there for is "decorating" the API for swagger. I could see other developers getting quickly confused why there is so much orchestration for setting up an API?

Example Controller action:
[HttpPost("transaction")] // /inventory/transaction
[Produces(System.Net.Mime.MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task SaveInventoryTransaction(InventoryTransaction model)
=> await this.Execute(
new SaveInventoryTransactionEvent { Model = new InventoryTransaction[] { model } },
// Return 202, not 200 because 200 means we've processed it, whereas 202 means we've accepted it for processing later
res => this.Accepted()
);

Collapse
 
djnitehawk profile image
Dĵ ΝιΓΞΗΛψΚ

give this article a read: bradjolicoeur.com/Article/fast-end...

Collapse
 
thetrigger profile image
ƒabio

Hello, how can I use app.UseFastEndpoints with IApplicationBuilder instead of WebApplication? since i'm not using a minimal API

Collapse
 
thetrigger profile image
ƒabio

Nvm, the solution is

app.UseFastEndpointsMiddleware();
app.UseEndpoints(endpoints =>
{
    endpoints.MapFastEndpoints();
Enter fullscreen mode Exit fullscreen mode
Collapse
 
renamed profile image
Renato Moura Martins Medeiros

A good article, but I thought there would be a series of one explaining other topics, such as dealing with login, jwt and so on.

Collapse
 
djnitehawk profile image
Dĵ ΝιΓΞΗΛψΚ • Edited

planning to write those articles in the future as time permits. however, the documentation is quite easy to follow in the meantime if you're interested. thanks!

Collapse
 
msoffsite profile image
Mark Stone

Yeah, no, it isn't. I've successfully generated a token and am trying to access a simple endpoint without request that is a get and returns only a string. I followed the example given at fast-endpoints.com/docs/security#s.... JWT authorizes fine but I am getting a 401 and 403. According to fast-endpoints.com/docs/swagger-su..., because AllowAnonymous isn't within the configure method and the 403 because something is forbidden but who knows what.

Thread Thread
 
djnitehawk profile image
Dĵ ΝιΓΞΗΛψΚ

probably a minor misconfiguration issue. pls create a support ticket with repro code either on discord or github. we'll get you sorted 😉

Thread Thread
 
msoffsite profile image
Mark Stone

I was able to get it fixed. Turned out it was the policy portion that messed me up a bit.