Controller
First, we need to clarify how the backend application in .NET works. When the WebApplication type application starts, it opens a port and waits for HTTP requests. The request is parsed and transformed into an HTTP context. This context is sent to an execution pipeline that will call the appropriate routines for that request and return the response back through the pipeline. Each step executed in the pipeline is called middleware, and some of these can be defined by developers. The last middleware executed in request handling calls developer-defined classes of type controller. In the controller, you specify which endpoints/routes from the server's API correspond to which methods in that class. A controller class is a special class whose public methods are called when the routes corresponding to the method are accessed. These classes inherit from ControllerBase. For the framework to identify controllers and routes, the class and methods are decorated with attributes, classes that extend the Attribute class. For example, [ApiController] decorates a controller class, specifying to the framework that this class should be used as a controller; [Route("api/[controller]")] on the class and [HttpGet("my-route")] on the method in the controller specify that when the route "/api/<controller_class_name>/my-route" is accessed with an HTTP GET request, that method is called.
Decorating classes and methods with attributes, or with annotations in Java, to acquire more functionalities at runtime or compile time, is a case of @OP (attribute-oriented programming).
In HTTP requests, data sent to the server can be specified in various locations within the request, which can be automatically extracted and passed as parameters to the controller method corresponding to the route. These locations are specified using attributes in front of the parameters in the following ways:
- For parameters specified in a route, such as "api/{type}/user/{id:guid}", you can see it described below. You can also see that constraints can be set, such as the "id" parameter being formatted as a Guid, automatically returning a BadRequest code to the client if the string is in the wrong format.
[HttpGet("api/{type}/user/{id:guid}")]
public Task<IActionResult> MyMethod([FromRoute] string type, [FromRoute] Guid id);
- URL/query parameters are specified with [FromQuery].
- Fields from the request header are extracted through [FromHeader].
- Route parameters are extracted via [FromRoute] if they were declared in the route template from an attribute specifying the route.
- For forms, fields from the form can be extracted through [FromForm]; a special case is when a field is a file, which can be extracted into an IFormFile or IFormFileCollection object.
- The request body can only be extracted once and deserialized into a single object, either by leaving the parameter without an attribute or with the [FromBody] attribute; only POST and PUT methods accept a body.
Routes called from the backend will respond with an object that will be automatically serialized into an HTTP response as JSON if it needs to be returned, along with a status code for the response. To set the status code and wrap the returned response, use methods from ControllerBase such as Ok(), BadRequest(), or Forbid(), or more explicitly through the StatusCode() method.
Use the appropriate status codes for different success or error cases. It is good practice for documenting your API, both for people and systems connected to it, reducing ambiguities and preventing error cases for API clients.
You can follow the Gitlab code for controllers with explanations and examples of how to use request information for backend actions. Working with a controller is largely intuitive. Besides decorating public methods with attributes, services that implement the application logic must be injected to be called here.
Do not implement logic directly in the controller; each component should fulfill a specific functionality. If a repository component only works with data storage, and services implement the application's logic, then a controller component should only handle the request and wrap the response, occasionally rudimentarily checking user access on routes to prevent unwanted access. A measure of well-written code is the segregation of responsibilities across components as much as possible.
Below is an example for a controller that on the route "api/UserFile/Download/{id}" with a GET method will send a file to the client or an error response with a body and status code.
[ApiController]
[Route("api/[controller]/[action]")]
public class UserFileController : BaseController
{
private readonly IUserFileService _userFileService;
public UserFileController(IUserFileService userFileService)
{
_userFileService = userFileService;
}
[HttpGet("{id:guid}")]
[Produces(MediaTypeNames.Application.Octet, MediaTypeNames.Application.Json)]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
public async Task<ActionResult<RequestResponse>> Download([FromRoute] Guid id)
{
var file = await _userFileService.GetFileDownload(id);
return file.Result != null ?
File(file.Result.Stream, MediaTypeNames.Application.Octet, file.Result.Name) :
NotFound(file.Error);
}
}
CRUD Operations
For greater clarity, we will implement CRUD (Create Read Update Delete) operations in each controller. Generally, for each entity in the database, or at least for a subset exposed to the user, basic operations can be performed. These are the four main CRUD operations:
- Create - adding operations, usually executed through POST requests.
- Read - reading object operations, usually GET requests. A single object or multiple objects can be read. If a large amount of data can be extracted, lists of objects should be paginated to avoid overloading both the server and client with unnecessary data.
- Update - data modification operations on the server, usually PUT requests.
- Delete - deletion operations, either complete data deletion or just invalidation (soft delete), usually DELETE requests.
For the actual data transfer, base database entities are not used directly; instead, DTOs (Data Transfer Objects) are used, projecting the entities onto these for security, performance, and to prevent errors. DTOs are just plain objects not managed by the ORM, through which information can be transferred from entities to various places in the application and to the outside.
As much as possible, adhere to the convention of associating these types of operations with HTTP methods. It is good practice to make the API easier to understand for those who will use it.
Swagger/OpenAPI Specifications
The advantage of using .NET is that any new web API project is created with the Swashbuckle package, which exposes OpenAPI routes, historically known as Swagger. OpenAPI is a standard through which a REST web API can be described in JSON or YML format. The advantages of using OpenAPI are:
- Ease of understanding the API, with information exposed about the API including routes, methods on routes, and the type of objects exchanged between client and server.
- Automation of interconnection between external clients and the API.
- Automatically updated Swagger UI for interface testing.
- The ability to use code generators for API clients.
- Serves as documentation.