Introduction
This is yet another blog post about how to build a REST API, however from my perspective and focusing on using Microsoft .NET technologies.
Guidelines
- Consistency - While it’s not essential to adhere strictly to particular standards, it’s crucial to maintain consistency in your approach.
- Follow the OpenAPI spec, either generate the OpenAPI spec from your API or write the spec and generate your API. Having an OpenAPI spec means a client can be generated, documentation can be generated, contract testing tooling can be bootstrapped and more.
- Use nouns instead of verbs
GET /users
, notGET /getUsers
- Use plurals instead of singular nouns
GET /users/1/activites/10
andGET /users/1/activites
instead ofGET /user/1/activity/10
andGET /user/1/activity
- Use the right verbs
GET
, Retrieve a recordGET /users/1
or collection of recordsGET /users
POST
, Create a recordPUT
, Update a record, should be idempotent - calling it once or many times produces the same result with no side effectsDELETE
, Delete a recordPATCH
, Partially update a record by supplying only the necessary information. Can be combined with a standard likeJSON Patch
- Use the right status codes, I most commonly find myself using the following:
200 - OK
, Succesful, with the response containing a body. For aGET
this might be the requested resource, for aPOST
this might be the created resource204 - No Content
, Succesful, but no response body. We’ve done aPUT
to update a resource400 - Bad Request
, Something in the request object is invalid, this could be something like the name of a property or the value of a property401 - Unauthorized
, You’re not logged in403 - Forbidden
, You’re logged in but you can’t access this resource404 - Not Found
, It doesn’t exist409 - Conflict
, You’re trying to create something that already exists500 - Internal Server Error
, 40x error codes indicate a problem on the client, and 500 indicates something unexpected went wrong on the server.
- Avoid nesting, or at least avoid nesting more than one level deep. Nesting creates a dependency between the entities.
- Use the query string to Filter, this could be general filtering based on a value, sorting, paging, or selection of fields.
GET /users?country=UK
GET /users?sort=birthdate_date:asc
GET /users?limit=120&offset=1
GET /users/1?fields=name,email
orGET /users?fields=name,email
for a collection
- Error handling should be done in a consistent way that can be handled by the calling client with ease. I like to split an error into a unique identifier, a name, and a description
1 { 2 "code": "validation-error", 3 "message": "Name too long", 4 "description": "Name can't contain more than 100 characters" 5 }
Wiggle room
As with a lot of patterns and principles, how they’re used can depend on the context. As an example I think it’s fine to do a PUT
to update a record and then return that record in the response, sometimes this is more convenient, maybe there are calculated fields that would require the following GET
to retrieve but by returning the object in the response we can remove the need for that GET
. An example might be that we display the last updated date of the record. What I believe is important is that we’re consistent with this behavior, if we’re returning an object as part of a PUT
for one entity we should do it for all.
What I like about REST
It is both well-defined and straightforward. Most developers have an understanding of at least basic REST principles. It’s popular and there’s a lot of tooling available to build REST APIs and clients.
What I don’t like about REST
It can lead you down a Create, Read, Update, Delete (CRUD) path, where backend logic consists of complex create, read, update and delete processes. This is fine for simple use cases, but what if an entity has many properties, and different properties are updated because of different scenarios? An entity might have many reasons to change, and what if we wanted more context for the reason for change
1/PUT /users/1
2{
3 name: "Jack",
4 email: "email@domain.com",
5 subscriptionActive: false
6}
vs
1/PUT /users/1/netflixSubscriptionExpired
Honarable mention - HATEOAS - Hypermedia as the engine of application state
I’ve come across HATEOAS and I think it’s quite cool as a concept, however realistically I see it as a standard that can only work when you have tight control over both the API and the calling client(s). It’s very easy for a client to just not follow the standard and for an engineer to hand crank URLs, which means if the developers of the API want to change a URL it will break the client.
Putting it all together as a .NET Minimal API
Firstly I like to use the RouteGroupBuilder
to create a group that represents the resource I’m working with, followed by an extension method that’s used to configure the group, this cuts down on the amount of repetition when not working with a group. Instead of inlining the delegate, I find it cleaner to add a method that includes the relevant arguments and calls a dependency-injected service.
1public static void AddUserFeature(this WebApplication app)
2{
3 app.MapGroup("/users").SetupUsersRouting();
4}
5
6public static RouteGroupBuilder SetupUsersRouting(this RouteGroupBuilder group)
7{
8 group.MapGet("/", GetUsers);
9 group.MapGet("/{userId:int}", GetUser);
10 group.MapPost("/", CreateUser);
11 group.MapPut("/{userId:int}", UpdateUser);
12 group.MapDelete("/{userId:int}", DeleteUser);
13
14 return group;
15}
16
17private static async Task<NoContent> UpdateUser(
18 [FromServices] UserUpdateService service,
19 [FromRoute] int userId,
20 [FromBody] UserDto user)
21{
22 await service.Invoke(userId, user);
23 return TypedResults.NoContent();
24}
25
26private static async Task<Ok<UserDto>> CreateUser(
27 [FromServices] UserCreateService service,
28 [FromBody] UserDto user)
29{
30 var userResult = await service.Invoke(user);
31 return TypedResults.Ok(userResult);
32}
33
34private static async Task<NoContent> DeleteUser(
35 [FromServices] UserDeleteService service,
36 [FromRoute] int userId)
37{
38 await service.Invoke(userId);
39 return TypedResults.NoContent();
40}
41
42private static async Task<Ok<UserDto>> GetUser(
43 [FromServices] UserGetService service,
44 [FromRoute] int userId)
45{
46 var user = await service.Invoke(userId);
47 return TypedResults.Ok(user);
48}
49
50private static async Task<Ok<IReadOnlyCollection<UserDto>>> GetUsers(
51 [FromServices] UserGetManyService service,
52 [FromQuery] PagingData pagingData)
53{
54 var users = await service.Invoke(pagingData);
55 return TypedResults.Ok(users);
56}