REST API Guidelines
This document covers best practices for designing and implementing REST APIs in Artemis, with a focus on Data Transfer Objects (DTOs) and proper API design.
Data Transfer Objects (DTOs)
Why Entities Must Not Be Used in JSON Serialization
Using JPA entities directly in REST controllers creates severe problems that compromise security, performance, and maintainability:
1. Security Vulnerabilities
- Mass Assignment Attacks: When entities are deserialized from JSON, attackers can inject values for fields they should not control (e.g.,
isAdmin,password, internal IDs) - Data Leakage: Entities may contain sensitive fields (passwords, internal flags, audit data) that get accidentally exposed in API responses
- Circular Reference Exploitation: Bidirectional relationships can be manipulated to cause infinite loops or expose related data
2. API Instability
- Breaking Changes: Any change to the database schema (renaming a column, adding a field) automatically changes the API contract, breaking clients
- No Versioning Control: Without DTOs, you cannot maintain multiple API versions or deprecate fields gracefully
- Tight Coupling: Frontend and backend become tightly coupled to the database schema
3. Performance Problems
- N+1 Query Issues: Lazy-loaded relationships get triggered during JSON serialization, causing unexpected database queries
- Over-fetching: Entire entity graphs are loaded when only a few fields are needed
- Serialization Overhead: Large entity hierarchies with bidirectional relationships are expensive to serialize
4. Hibernate Session Issues
- LazyInitializationException: Accessing lazy relationships outside the Hibernate session causes runtime exceptions
- Detached Entity Problems: Merging detached entities can overwrite data unexpectedly
- Proxy Object Serialization: Hibernate proxies don't serialize correctly, causing
ClassCastExceptionor incomplete data
5. Circular References and JsonIgnore Problems
Entities often have bidirectional relationships that create circular references during JSON serialization. While annotations like @JsonIgnore and @JsonIgnoreProperties can help, they introduce significant problems:
- Hard to Understand: Developers must mentally track which fields are ignored in which direction across multiple entity classes
- Impossible to Debug: When serialization behaves unexpectedly, tracing through
@JsonIgnoreannotations scattered across many files is extremely difficult - Inconsistent Behavior: The same entity might serialize differently depending on how it was loaded (with or without certain relationships)
- Fragile: Adding a new relationship or changing an existing one requires careful updates to multiple
@JsonIgnoreannotations
6. OpenAPI and API-Driven Development
API-driven development using OpenAPI (Swagger) is only possible when using DTOs:
- Circular References Break OpenAPI: Entities with bidirectional relationships cannot be properly represented in OpenAPI schemas, causing generation failures or infinite recursion
- Clean API Contracts: DTOs provide clear, flat data structures that OpenAPI can document accurately
- Code Generation: Client code generation from OpenAPI specs works reliably only with DTOs; entities produce broken or unusable generated code
- API-First Design: DTOs enable true API-first development where the API contract is designed before implementation
7. Validation Complexity
- Mixed Concerns: Entity validation rules (for persistence) differ from API validation rules (for input)
- Conditional Validation: Different endpoints may require different validation rules for the same data
Benefits of Using DTOs
DTOs provide essential separation between your API contract and your database schema:
| Benefit | Description |
|---|---|
| Security | Explicitly define which fields can be read/written, preventing mass assignment and data leakage |
| API Stability | Database changes don't break the API; you control exactly what clients see |
| Performance | Transfer only the data needed; avoid lazy loading issues and over-fetching |
| Network Efficiency | Minimize data transfer over the network by including only required fields, reducing bandwidth and latency |
| Data Privacy | Exclude sensitive or internal fields from API responses, ensuring clients only receive data they are authorized to see |
| Flexibility | Create different views of the same entity for different use cases (list view, detail view, admin view) |
| Validation | Apply endpoint-specific validation rules separate from persistence constraints |
| Documentation | DTOs serve as clear API documentation; field names and types are explicit |
| Testing | Easier to test API contracts independently from database schema |
| Versioning | Support multiple API versions by creating versioned DTOs |
| OpenAPI Compatibility | DTOs work seamlessly with OpenAPI for API documentation and code generation |
| No Circular References | DTOs eliminate circular reference problems entirely by design |
DTO Design Guidelines
Use Java Records for DTOs
Java records are ideal for DTOs because they are immutable, concise, and automatically provide equals(), hashCode(), and toString():
// Good: Immutable record DTO
public record UserDTO(
Long id,
String login,
String name,
String email
) {
// Static factory method to create from entity
public static UserDTO of(User user) {
return new UserDTO(
user.getId(),
user.getLogin(),
user.getName(),
user.getEmail()
);
}
}
Create Separate DTOs for Input and Output
Different operations often need different data:
// For creating a new exercise (input)
public record CreateExerciseDTO(
String title,
Double maxPoints,
ZonedDateTime dueDate
) {}
// For updating an existing exercise (input)
public record UpdateExerciseDTO(
Long id, // Required for updates
String title,
Double maxPoints,
ZonedDateTime dueDate
) {
// Method to apply changes to an existing entity
public void applyTo(Exercise exercise) {
exercise.setTitle(this.title);
exercise.setMaxPoints(this.maxPoints);
exercise.setDueDate(this.dueDate);
}
}
// For returning exercise data (output)
public record ExerciseDTO(
Long id,
String title,
Double maxPoints,
ZonedDateTime dueDate,
String courseName // Derived/computed field
) {
public static ExerciseDTO of(Exercise exercise) {
return new ExerciseDTO(
exercise.getId(),
exercise.getTitle(),
exercise.getMaxPoints(),
exercise.getDueDate(),
exercise.getCourse().getName()
);
}
}
REST Controller Example
@RestController
@RequestMapping("/api/exercises")
public class ExerciseResource {
// CORRECT: Accept DTO, return DTO
@PostMapping
public ResponseEntity<ExerciseDTO> createExercise(@RequestBody @Valid CreateExerciseDTO dto) {
Exercise exercise = new Exercise();
exercise.setTitle(dto.title());
exercise.setMaxPoints(dto.maxPoints());
exercise.setDueDate(dto.dueDate());
exercise = exerciseRepository.save(exercise);
return ResponseEntity.ok(ExerciseDTO.of(exercise));
}
// WRONG: Never do this - accepting entity directly
// @PostMapping
// public ResponseEntity<Exercise> createExercise(@RequestBody Exercise exercise) {
// return ResponseEntity.ok(exerciseRepository.save(exercise));
// }
}
DTO Design Rules
DTOs must be pure data containers with only:
- Primitive types and their wrappers (String, Long, Integer, Boolean, etc.)
- Date/time types (ZonedDateTime, Instant, LocalDate, etc.)
- Enums
- Other DTOs (for nested data structures)
- Collections of the above types
// WRONG: DTO wrapping an entity - defeats the purpose of DTOs!
public record BadExerciseDTO(
Long id,
Exercise exercise // NEVER do this - entity reference!
) {}
// WRONG: DTO containing a collection of entities
public record BadCourseDTO(
Long id,
String name,
Set<User> students // NEVER do this - entity collection!
) {}
// CORRECT: DTO with only primitive types and other DTOs
public record ExerciseDTO(
Long id,
String title,
Double maxPoints,
ZonedDateTime dueDate,
CourseInfoDTO course // Other DTO is fine
) {
public static ExerciseDTO of(Exercise exercise) {
return new ExerciseDTO(
exercise.getId(),
exercise.getTitle(),
exercise.getMaxPoints(),
exercise.getDueDate(),
CourseInfoDTO.of(exercise.getCourse())
);
}
}
// CORRECT: Nested DTO for related data
public record CourseInfoDTO(
Long id,
String name
) {
public static CourseInfoDTO of(Course course) {
return new CourseInfoDTO(course.getId(), course.getName());
}
}
DTO Composition
Java Records do not support class inheritance.
To share common attributes across multiple DTOs (e.g., standard fields like ID, creation date, or common exercise details)
without duplicating code, use composition combined with the @JsonUnwrapped annotation.
This technique keeps your Java code modular while ensuring the exposed REST API remains "flat" and easy for clients to consume.
How it works
- Define the Shared Component: Create a Record containing the common fields.
- Compose the DTO: Add the shared record as a field in your specific DTO.
- Annotate: Add
@JsonUnwrappedto the field. Jackson will flatten the properties of that field into the parent JSON object.
Example
1. Define the Common Base Create a reusable DTO for shared attributes.
public record ExerciseDetailsDTO(
String title,
Double maxPoints,
String difficulty
) {}
2. Compose the Specific DTO
public record ProgrammingExerciseDTO(
Long id,
String programmingLanguage,
String packageName,
@JsonUnwrapped
ExerciseDetailsDTO details // Contains title, maxPoints, etc.
) {}
3. Resulting JSON Structure When serialized, the JSON will be flat:
{
"id": 123,
"programmingLanguage": "Java",
"packageName": "de.example",
"title": "Implement a REST API",
"maxPoints": 100.0,
"difficulty": "Hard"
}
DTO Polymorphism
Since Java Records do not support class inheritance, you cannot use traditional inheritance trees for polymorphic request bodies (e.g., an endpoint that accepts a list of different quiz question types).
To solve this, we combine sealed interfaces with Jackson's type handling annotations. This allows the REST controller to receive a single interface, while Jackson automatically instantiates the correct underlying Record based on a "type discriminator" field in the JSON payload.
How it works
- Define a Sealed Interface: Create an interface that represents the base DTO and restrict implementations using the
sealedandpermitskeywords. - Add
@JsonTypeInfo: Tell Jackson how to find the type discriminator (usually a property namedtype). - Add
@JsonSubTypes: Map the possible string values of the discriminator to their respective Record implementations. - Implement the Interface: Have your specific Record DTOs implement this interface.
Example
1. Define the Polymorphic Interface
This interface handles the routing. Notice how Jackson maps the string names (e.g., "multiple-choice") to the specific DTO classes.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = MultipleChoiceQuestionCreateDTO.class, name = "multiple-choice"),
@JsonSubTypes.Type(value = DragAndDropQuestionCreateDTO.class, name = "drag-and-drop"),
@JsonSubTypes.Type(value = ShortAnswerQuestionCreateDTO.class, name = "short-answer")
})
public sealed interface QuizQuestionCreateDTO permits
MultipleChoiceQuestionCreateDTO,
DragAndDropQuestionCreateDTO,
ShortAnswerQuestionCreateDTO {
}
2. Implement the records Create the individual records that implement the sealed interface. They will act as standard DTOs.
public record MultipleChoiceQuestionCreateDTO(
String title,
int points,
List<String> answerOptions
) implements QuizQuestionCreateDTO {}
3. Resulting JSON Structure
When the client sends a request, it must include the type field defined in @JsonTypeInfo:
{
"type": "multiple-choice",
"title": "What is the capital of France?",
"points": 5,
"answerOptions": ["Berlin", "Paris", "Rome"]
}
4. Controller usage
In your REST controller, simply use the interface as the @RequestBody. Jackson handles the rest.
@PostMapping
public ResponseEntity<QuizQuestionDTO> createQuestion(@RequestBody @Valid QuizQuestionCreateDTO dto) {
// 'dto' will be an instance of MultipleChoiceQuestionCreateDTO,
// DragAndDropQuestionCreateDTO, or ShortAnswerQuestionCreateDTO
// depending on the "type" provided in the JSON.
if (dto instanceof MultipleChoiceQuestionCreateDTO mcq) {
// Handle multiple choice...
}
// ...
}
Discriminator Enums for Entity-Type Fields
When a DTO needs to expose which concrete subclass of an entity inheritance hierarchy a row represents (for example, the result of Hibernate's TYPE(...) JPQL function over an @Inheritance hierarchy), do not expose Class<? extends SomeEntity> as a DTO component. Serializing a Class writes its fully-qualified JVM name into the JSON payload, which couples every client (including cached stale browser tabs after a refactor) to the internal Java package layout — a class rename then silently breaks consumers.
Instead, define a discriminator enum whose @JsonValue produces the exact string the entity's @JsonSubTypes already emits, and expose that enum on the DTO. The canonical examples in Artemis are ExerciseType and LectureUnitType:
public enum ExerciseType {
TEXT("text"), PROGRAMMING("programming"), MODELING("modeling"), FILE_UPLOAD("file-upload"), QUIZ("quiz");
private final String value;
ExerciseType(String value) {
this.value = value;
}
@JsonValue
public String getValue() {
return value;
}
public static ExerciseType getExerciseTypeFromClass(Class<? extends Exercise> exerciseClass) {
return switch (exerciseClass.getSimpleName()) {
case "TextExercise" -> TEXT;
case "ProgrammingExercise" -> PROGRAMMING;
// ...
default -> throw new IllegalArgumentException("Unknown exercise class: " + exerciseClass.getName());
};
}
}
The enum's JSON values must match the entity's @JsonSubTypes name exactly. A client reading Exercise.type then sees the same string regardless of whether the source is the entity or a DTO — and a stable string contract survives package renames.
When the DTO is populated from a JPQL SELECT NEW ... TYPE(e) ... projection, add an overloaded record constructor that accepts the raw entity class and converts it via the enum helper. This keeps the canonical record component (the field that serialization observes) as the enum, while the JPQL projection continues to compile unchanged:
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record ExerciseInformationDTO(long id, ..., ExerciseType type, ...) {
/**
* JPQL constructor that accepts the raw entity class produced by Hibernate's {@code TYPE(...)} function
* and maps it to the {@link ExerciseType} discriminator used by the canonical record component.
*/
public ExerciseInformationDTO(long id, ..., Class<? extends Exercise> type, ...) {
this(id, ..., ExerciseType.getExerciseTypeFromClass(type), ...);
}
}
The overloaded constructor takes Class<> as a parameter, not as a field, so it does not violate the "no Class<> in DTOs" rule — that rule (and the architecture test enforcing it) targets record components and declared fields only.
Architecture Test Enforcement
We enforce DTO usage through architecture tests that verify:
- No entity return types: REST controllers must not return
@Entitytypes directly - No entity input types:
@RequestBodyand@RequestPartparameters must not be@Entitytypes - No entity fields in DTOs: DTO classes must not contain fields that reference
@Entitytypes (prevents lazy wrapper pattern) - No
Class<>fields in DTOs: DTO classes (in any..dto..package) must not declare fields of raw typejava.lang.Class— i.e. noClass<? extends X>or rawClassrecord components. Use a discriminator enum (see Discriminator Enums for Entity-Type Fields above). Enforced byArchitectureTest#testNoClassFieldsInDtos.
These tests run as part of CI and will fail if violations are introduced. Rules 1–3 are tracked per module with the goal of reducing the count to zero (see AbstractModuleEntityUsageArchitectureTest); rule 4 is a global zero-tolerance rule.
REST Controller Best Practices
Keep Controllers Clean and Focused
- RestControllers should be stateless
- RestControllers are by default singletons
- RestControllers should not execute business logic but rely on delegation to
@Serviceclasses - RestControllers should deal with the HTTP layer of the application: handle access control, input data validation, output data cleanup (if necessary), and error handling
- RestControllers should be oriented around a use-case/business-capability
- RestControllers must always return DTOs that are as small as possible (focus on data economy to improve performance and follow data privacy principles)
Route Naming Conventions
- Always use kebab-case (e.g.,
.../exampleAssessment→.../example-assessment) - Routes should follow the general structure:
list-entity > entityId > sub-entity(e.g.,exercises/{exerciseId}/participations) - Use plural for list-entities (e.g.,
exercises/...), singular for singletons (e.g.,.../assessment), and verbs for remote methods (e.g.,.../submit) - Specify the key entity at the end of the route (e.g.,
text-editor/participations/{participationId}should beparticipations/{participationId}/text-editor) - Use consistent routes that start with
courses,exercises,participations,exams, orlecturesto simplify access control - When defining a new route, all subroutes should be addressable (e.g., if your new route is
exercises/{exerciseId}/statistics, then bothexercises/{exerciseId}andexercisesshould be addressable) - For alternative representations that send extra data (e.g., for assessment), specify the reason at the end of the route:
participations/{participationId}/for-assessment
Path Structure: module / collection / {id}
Each REST path is built from these kinds of segments, in this order:
- Module — the path always starts with the server module name (the package/folder under
de.tum.cit.aet.artemis, e.g.course,exam,programming).api/course/...is the API of the course module. Enforced byAbstractModuleResourceArchitectureTest#shouldCorrectlyUseRequestMappingAnnotations. - Collection (plural) + id (singular) — after the module there is always a resource collection in plural (
courses,exercises,exams,lectures, …). An id that narrows the collection to a single element must immediately follow its collection and match its name exactly:courses/{courseId},exercises/{exerciseId},exams/{examId}. Never place a bare id directly after the module (api/notification/{courseId}❌) or after another id — an id must always be preceded by its matching plural collection. Enforced byAbstractModuleResourceArchitectureTest#restPathVariablesMustBePairedWithTheirCollection. - Query parameters (optional) — use query parameters to further filter, search, or paginate when the value is not part of the resource hierarchy (e.g.
.../users/search?loginOrName=foo&roles=students). Prefer a query parameter over a path segment when the entity is only a filter and not a parent of the addressed resource.
| ❌ Avoid | ✅ Use |
|---|---|
api/notification/{courseId}/settings (id after module) | api/notification/courses/{courseId}/settings |
api/atlas/learning-path/{learningPathId} (singular collection) | api/atlas/learning-paths/{learningPathId} |
api/exercise/{exerciseId}/versions (id after module) | api/exercise/exercises/{exerciseId}/versions |
api/account/account/profile-picture (module name repeated as resource) | api/account/profile-picture |
Subtype collections are allowed when the id is the supertype id, because the collection ends with the matching plural: api/programming/programming-exercises/{exerciseId} ✅ (programming-exercises ends with exercises, the plural of exerciseId).
Common violations and how to fix each
Every violation falls into one of these patterns. Each example is a real path from the Artemis API; pick the fix that matches your case and keep the old path as a deprecated alias (see below).
| Pattern | ❌ Before | ✅ After | How |
|---|---|---|---|
| Singular collection | api/programming/auxiliary-repository/{auxiliaryRepositoryId} | api/programming/auxiliary-repositories/{auxiliaryRepositoryId} | Pluralize the collection so it is the plural of the id stem. |
| Floating id (id straight after the module, or after another id) | api/notification/{courseId}/settings | api/notification/courses/{courseId}/settings | Insert the matching plural collection before the id. |
| Key entity not at the end (operation modeled as a collection) | api/programming/repository/{participationId}/files | api/programming/participations/{participationId}/repository/files | Reorder so the id pairs with its real collection; the operation/sub-resource (repository, text-editor) comes after. |
| Source / qualified id as a path segment | api/programming/programming-exercises/import/{sourceExerciseId} | api/programming/programming-exercises/import?sourceExerciseId={id} (or) api/programming/programming-exercises/{sourceExerciseId}/import | If the id is only a parameter of the action, make it a query parameter; if it identifies a real parent resource, nest the action under that resource. |
| Collection name ≠ id name | api/account/passkey/{credentialId} | api/account/passkeys/{passkeyId} (or) api/account/credentials/{credentialId} | Rename either the collection or the id so they match exactly — an API naming decision; do not leave them divergent. |
The arch rule reports the first unpaired id on a path, so fixing one segment can surface a second (e.g. pluralizing tutorial-groups-configuration revealed that the nested tutorial-free-periods/{tutorialGroupFreePeriodId} collection still mismatches its id). Re-run the test after each fix.
Migrating a path without breaking clients
Mobile apps (iOS, Android) and the VS Code extension consume these paths and cannot be updated in lockstep, so never remove or rename a path outright. Map the canonical path and the old path on the same handler — canonical first, legacy second — so the old one keeps working as a deprecated alias:
// Class-level: the canonical prefix carries the collection; the legacy prefix stays unchanged.
@RequestMapping({ "api/notification/courses/", "api/communication/notification/" })
// Method-level (single class prefix): list the canonical path first, the legacy path second.
@GetMapping({ "exercises/{exerciseId}/versions", "{exerciseId}/versions" })
The first entry is the canonical successor; every later entry is a deprecated alias. For class-level dual prefixes, LegacyApiPathDeprecationInterceptor automatically tags responses served under a legacy prefix with Deprecation, Sunset, and Link: <successor>; rel="successor-version" headers. Record the deprecated prefix in the module's *LegacyRestPaths class (annotated @Deprecated(forRemoval = true)) so the eventual cleanup is mechanical.
Further reading
These conventions follow widely adopted industry REST guidelines:
- Zalando RESTful API Guidelines — Pluralize resource names & Identify resources and sub-resources via path segments
- Microsoft Azure REST API Guidelines — Resource paths
- Google API Improvement Proposals — AIP-122 Resource names
Controller Method Guidelines
- The REST controller's route should end with a trailing "/" and not start with a "/" (e.g.,
api/); individual endpoint routes should not start or end with a "/" (e.g.,exercises/{exerciseId}) - Use
...ElseThrowalternatives of Repository and AuthorizationCheck calls for increased readability (e.g.,findByIdElseThrow(...)instead offindById(...)and then checking fornull) - POST should return a DTO representing the newly created resource
- POST should be used to trigger remote methods (e.g.,
.../{participationId}/submit) - Never trust user input; always check if the passed data exists in the database
- Verify consistency of user input (e.g., check if IDs in body and path match, compare course in
@RequestBodywith the one referenced by ID in the path) - Check for user input consistency first, then check authorization (mismatched course IDs could lead to unauthorized access)
- Handle exceptions and errors with a standard response
- Express the HTTP verb with the dedicated shortcut annotations (
@GetMapping,@PostMapping,@PutMapping,@PatchMapping,@DeleteMapping);@RequestMappingis reserved for the class-level path prefix and must not annotate an endpoint method at all. Enforced byAbstractModuleResourceArchitectureTest#endpointsShouldUseHttpMethodShortcutAnnotations. - Endpoint methods must be
public. Enforced byAbstractModuleResourceArchitectureTest#endpointsMustBePublic. - The handler method name must match its HTTP verb: a
@GetMappingmust read (get*/list*/find*/…) and must not be named like a mutation; a@DeleteMappingmust delete (delete*/remove*/…) and must not be named like a read or a create. Enforced byAbstractModuleResourceArchitectureTest#endpointMethodNamesShouldMatchTheirHttpVerb. GETandDELETEendpoints must not declare a@RequestBody— a GET request body has no defined semantics (RFC 9110) and a DELETE body is widely ignored; pass data via path or query parameters instead. Enforced byAbstractModuleResourceArchitectureTest#getEndpointsShouldNotDeclareRequestBodyand#deleteEndpointsShouldNotDeclareRequestBody.- Migrating an existing body off a GET/DELETE without breaking clients: accept the data as query parameters and keep the old
@RequestBodyas an optional, deprecated parameter (@RequestBody(required = false)), preferring the query parameters and logging a deprecation warning when the body is used. Add the endpoint toDELETE_REQUEST_BODY_BASELINEwith a documented removal plan, and drop the body parameter (and the baseline entry) once all clients — including the mobile apps — have migrated. SeePushNotificationResource#unregisterfor the reference pattern.
- Migrating an existing body off a GET/DELETE without breaking clients: accept the data as query parameters and keep the old
- Endpoints must return
ResponseEntity;ModelAndViewis forbidden. Enforced byAbstractModuleResourceArchitectureTest#allPublicMethodsShouldReturnResponseEntityand#restEndpointsShouldNotReturnModelAndView.
Request Validation
Define Constraints on DTOs
Always validate incoming DTOs using Bean Validation:
public record CreateExerciseDTO(
@NotBlank @Size(max = 255)
String title,
@NotNull @Positive
Double maxPoints,
@NotNull @Future
ZonedDateTime dueDate
) {}
Enable Validation in Controllers
Validation is not automatic. You must annotate the DTO parameter in your REST controller method with @Valid.
If this annotation is missing, the constraints defined in your DTO will be ignored.
@PostMapping
// The @Valid annotation triggers the validation logic before the method body is executed
public ResponseEntity<ExerciseDTO> createExercise(@RequestBody @Valid CreateExerciseDTO exerciseDTO) {
// ...
}
Nested Validation (Cascading)
If your DTO contains other DTOs (as fields or collections), validation does not cascade automatically.
You must annotate the nested field with @Valid to ensure the inner objects are also checked.
public record ExamDTO(
@NotBlank
String title,
// Critical: The @Valid annotation here ensures that properties
// inside 'examConfig' are validated when the ExamDTO is validated.
@Valid
@NotNull
ExamConfigDTO examConfig,
// Also works for collections: This validates every StudentDTO inside the list
@NotEmpty
List<@Valid StudentDTO> registeredStudents
) {}
Response Codes
Use appropriate HTTP status codes:
| Code | Usage |
|---|---|
| 200 OK | Successful GET, PUT, PATCH |
| 201 Created | Successful POST that creates a resource |
| 204 No Content | Successful DELETE |
| 400 Bad Request | Invalid input / validation error |
| 401 Unauthorized | Missing or invalid authentication |
| 403 Forbidden | Authenticated but not authorized |
| 404 Not Found | Resource doesn't exist |
| 409 Conflict | Resource conflict (e.g., duplicate) |
Error Responses
Return consistent error DTOs:
public record ErrorDTO(
String title,
String message,
int status,
ZonedDateTime timestamp
) {
public static ErrorDTO of(String title, String message, int status) {
return new ErrorDTO(title, message, status, ZonedDateTime.now());
}
}
Authorization
To reject unauthorized requests as early as possible, Artemis employs two solutions:
-
Implicit pre- and post-authorization annotations:
@AllowedTools(ToolTokenType.__)ensures tool-based requests can only access specific endpoints following the Principle of Least Privilege@EnforceRoleInResource(e.g.,@EnforceAtLeastInstructorInCourse) annotations block users with wrong or missing authorization roles without querying the database- If necessary, these annotations check for access rights to individual resources via lightweight queries
- Currently available:
@EnforceRoleInCourseand@EnforceRoleInExercise
-
Explicit authorization checks (two-step process):
@EnforceAtLeastRole(e.g.,@EnforceAtLeastInstructor) blocks users with wrong or missing authorization roles without database queries- The
AuthorizationCheckServicechecks access rights to individual resources by querying the database (must be performed explicitly)
Role Hierarchy
Artemis distinguishes between six different roles: ADMIN, INSTRUCTOR, EDITOR, TA (teaching assistant/tutor), USER, and ANONYMOUS. Each role has all the access rights of the roles following it (e.g., ANONYMOUS has almost no rights, while ADMIN users can access every page).
| Minimum Role | Endpoint Annotation | Path Prefix | Package |
|---|---|---|---|
| ADMIN | @EnforceAdmin | /api/{module}/admin/ | {module}.web.admin |
| INSTRUCTOR | @EnforceAtLeastInstructor | /api/{module}/ | {module}.web |
| EDITOR | @EnforceAtLeastEditor | /api/{module}/ | {module}.web |
| TA | @EnforceAtLeastTutor | /api/{module}/ | {module}.web |
| USER | @EnforceAtLeastStudent | /api/{module}/ | {module}.web |
| ANONYMOUS | @EnforceNothing | /api/{module}/public/ | {module}.web.open |
If you need to deviate from these rules, use @ManualConfig. Use this annotation only if absolutely necessary as it will exclude the endpoint from automatic authorization tests.
Use the @Internal annotation to mark methods or classes that should only be accessed from trusted internal networks. Access is restricted based on configured CIDR blocks in InternalAccessConfiguration (artemis.security.internal.allowed-cidrs).
Implicit Authorization (Preferred)
The following example makes the call accessible only to ADMIN and INSTRUCTOR users and checks access rights to the course in the database:
Do not write:
@EnforceAtLeastInstructor
public ResponseEntity<Void> enableLearningPathsForCourse(@PathVariable long courseId) {
var course = courseRepository.findById(courseId);
authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null);
[...]
return ResponseEntity.ok().build();
}
Instead, use:
@EnforceAtLeastInstructorInCourse
public ResponseEntity<Void> enableLearningPathsForCourse(@PathVariable long courseId) {
[...]
return ResponseEntity.ok().build();
}
Explicit Authorization
Always annotate your REST endpoints with the annotation for the minimum role that has access:
@EnforceAtLeastInstructor
public ResponseEntity<Void> enableLearningPath(@PathVariable long courseId) {
var course = courseRepository.findById(courseId);
authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null);
[...]
return ResponseEntity.ok().build();
}
If a user passes pre-authorization, access to individual resources still needs to be checked. For example, a user can be a teaching assistant in one course but only a student in another:
// Pass 'null' instead of a user - the service will fetch the user object
// and check if the user has at least the given role and access to the resource
authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, exercise, null);
Use the AuthorizationCheckService to reduce duplication:
@GetMapping("courses/{courseId}/programming-exercises")
@EnforceAtLeastTutor
public ResponseEntity<List<ProgrammingExercise>> getActiveProgrammingExercisesForCourse(@PathVariable Long courseId) {
Course course = courseRepository.findByIdElseThrow(courseId);
authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.TEACHING_ASSISTANT, course, null);
List<ProgrammingExercise> exercises = programmingExerciseService.findActiveExercisesByCourseId(courseId);
return ResponseEntity.ok().body(exercises);
}
The course repository call throws a 404 Not Found exception if no matching course exists. The AuthorizationCheckService throws a 403 Forbidden exception if the user is unauthorized.
Tool-Based Authorization
To enforce minimal access for external tools, Artemis provides @AllowedTools. This annotation restricts Tool Tokens to certain endpoints. A Tool Token is a JWT with the claim "tools": "TOOLTYPE".
How it works:
- Requests without a tool claim (e.g., browser requests) remain subject to role-based authorization rules
- Requests with a tool claim (e.g.,
{"tools": "SCORPIO"}) can only access endpoints annotated with@AllowedTools(ToolTokenType.__) - If a tool tries to access an unannotated endpoint, it receives a
403 Forbiddenresponse
Example:
@AllowedTools(ToolTokenType.SCORPIO)
public ResponseEntity<CourseForDashboardDTO> getCourseForDashboard(@PathVariable long courseId) {
[...]
return ResponseEntity.ok(courseForDashboardDTO);
}
Best Practices:
- Requests without a tool claim are unrestricted
- Tool-based requests must be explicitly allowed with
@AllowedTools - Follow the Principle of Least Privilege; use tool tokens whenever possible
- For multiple tools:
@AllowedTools({ToolTokenType.SCORPIO, ToolTokenType.ANDROID})
How to Get Tool Tokens:
- Verify the tool type is defined in
ToolTokenType.java - Send a POST request to
{{base_url}}/api/core/public/authenticate?tool=TOOLTYPE
JSON Serialization
Always use ObjectMapper (Jackson) for JSON serialization and deserialization. Do not use other libraries.