Skip to main content

API

Data Transfer Objects (DTOs)

We use DTOs to decouple the API contract from the domain model. For each domain model that is serialized through the API, there should be a corresponding DTO class. Moreover, there should be DTO classes for nested fields if these are domain models.

For mapping models to DTOs, a separate mapper interface should be created for a model using the @Mapper annotation provided by MapStruct. As MapStruct is capable of mapping nested fields automatically to their corresponding DTO classes, mapper interfaces should only be created for models when they must be directly invoked. For example, CourseMapper maps the Course entity to CourseDto and automatically maps the contained Term entity to TermDto given that the term field in Course matches the term field in CourseDto. While there is a dedicated TermMapper interface, this is not required here. It only exists because other parts of the code have to directly invoke TermMapper to obtain a TermDto.

When defining mapper interfaces, existing interfaces should be reused by registering them through the uses attribute of the @Mapper annotation. For example, CourseMapper reuses TermMapper because it is defined as follows:

@Mapper(
componentModel = "spring",
uses = {TermMapper.class, UserMapper.class, ProgramModuleMapper.class})
public interface CourseMapper {
CourseDto map(Course course);
}

In some cases, polymorphic domain models must be mapped to DTOs. For example, there are various subclasses of AssessmentProgress, such as BinaryAssessmentProgress and PointsAssessmentProgress. Then, use @SubclassMapping together with @BeanMapping(subclassExhaustiveStrategy = SubclassExhaustiveStrategy.RUNTIME_EXCEPTION) to map them to their corresponding DTO class. This ensures that mapping does not fail silently when encountering unmapped subtypes. See UnitAssessmentProgressMapper for details.

Controllers and Application Services

Endpoints should be defined by declaring methods in classes annotated with @RestController. Moreover, controller classes should never access the database, check for permissions, involve domain logic, or perform DTO mapping. Instead, they should always delegate to an application service. For example, CourseController#create delegates to CourseService#create, which handles a course creation request and returns a CourseDto for the created Course entity.

This also affects Optional<X> values, which may represent the values of request parameters. These should be passed into the service method and unwrapped inside the service, not the controller.

When there is considerable domain logic involved, define additional domain services and call them from inside an application service. For example, TaskPoolService is an application service where syncPool performs permission checking and then delegates to TaskPoolSyncService for performing the task import.

Error Handling

Error handling follows an aspect-oriented approach. A single @ControllerAdvice class (GlobalExceptionHandler) intercepts unhandled exceptions raised while processing a request and translates them into error responses. This requires custom exceptions that are intended to signal API errors (such as CourseNotFoundException) to be annotated with @ResponseStatus for setting the status code and reason (a human-readable, general description of the error). When the reason must be built dynamically from an exception's fields, the exception can provide a dynamic reason by implementing the ReasonProvider interface. See JoinsExceededException for an example.

Sometimes the status code tied to an exception needs to change for a specific endpoint. For example, CourseController#create throws a CourseNotFoundException when the template course referenced in the payload cannot be found. Here, the status code 400 fits better than the exception's default status (404). Thus, the controller method is annoted with @ResponseStatusMapping to remap the status code for that endpoint (see the JavaDocs for details).

When a client needs to react to a specific error, the exception should be annotated with @Error to assign it a stable ErrorCode. If the client also needs additional error details in a machine-readable format, they should be exposed through fields on the exception class and the class should be annotated with @Expose. Then, the ErrorDataSerializer component will handle serialization to obtain an appropriate error representation.

Swagger

springdoc generates an OpenAPI schema based on controller and DTO classes and configures SwaggerUI so that the docs can be accessed at /swagger-ui. Methods that declare endpoints and DTOs should be documented with springdoc's JavaDoc support, or with the @Operation and @Parameter annotations. As springdoc cannot infer the errors an endpoint may return automatically, each endpoint needs to be annotated with @SubatoApiResponse to explicitly document them. SubatoApiResponseCustomizer then merges this documentation into the generated OpenAPI schema.

Rate Limiting

To keep clients from monopolizing server resources, requests are rate limited to 100 requests/minute (anonymous users) and 200 requests/minute (authenticated users). Every response reports the remaining budget in the X-Rate-Limit-Remaining header, and requests that exceed the budget are rejected with status code 429.