REST-APIs sind ein wichtiges Thema für die Entwicklung von Client-Server-Systemen. Mittels REST können Client (z.B. eine Web Application in Angular, Vue oder React) und Server Daten austauschen. Nachfolgend sind einige zentrale Hinweise für die Entwicklung von REST-APIs mit Spring Boot zusammengefasst.
Die Code-Basis für die Beispiele in diesem Dokument beziehen sich auf das Beispielprojekt Internship Management. Dafür gibt es unter https://git.archi-lab.io/public-repos/internship/internship-rest-solution ein öffentliches Repository, das Sie sich clonen können.
Das Beispiel bezieht sich auf die Verwendung von Spring Web MVC in Kombination mit Spring Data JPA. Hier implementiert man die REST-Controller mit den Endpoint-Methoden explizit. (Es gibt auch noch eine weitere Möglichkeit, REST-APIs in Spring Boot zu entwickeln, nämlich mit Spring Data REST. Die setzen wir aber wegen “zu viel Magic” im ST2-Praktikum nicht ein.)
Ein REST-Controller in Spring Boot ist eine Klasse, die mit der Annotation @RestController
versehen ist.
Die Methoden des Controllers sind mit der Annotation @RequestMapping
oder einer spezifischeren Annotation
wie @GetMapping
, @PostMapping
, @PutMapping
oder @DeleteMapping
versehen.
@RestController
public class StudentRESTController {
//...
@GetMapping("/students")
public ResponseEntity<List<StudentDTO>> getAllStudents() {
List<Student> foundStudents = studentApplicationService.findAllStudents();
List foundDtos = new ArrayList<StudentDTO>();
ModelMapper modelMapper = new ModelMapper();
for ( Student student : foundStudents ) {
foundDtos.add( modelMapper.map( student, StudentDTO.class ) );
}
return ResponseEntity.ok().body( foundDtos );
}
//...
}
(siehe StudentRESTController.java)
Jede Methode des Controllers stellt einen REST-Endpoint dar. Die Annotation @GetMapping
z.B. gibt an, dass
die Methode auf einen HTTP-GET-Request reagiert.
An dem obigen Code-Beispiel sieht man noch einige weitere Prinzipien, denen
REST-Endpoint-Methoden folgen sollten.
ModelMapper
verwenden.
Der schaut nach den DTO-Properties und versucht diese nach Namen zu matchen. Alternativ kann
man eigene Mapper schreiben - siehe unten.ResponseEntity
als Rückgabetyp: REST-Endpoint-Methoden sollten ein ResponseEntity
-Objekt
zurückgeben. Das ResponseEntity
-Objekt enthält den HTTP-Statuscode und den Response-Body.
(Spring ist dabei “robust” - man kann auch einfach ein beliebiges Objekt zurückgeben, und
Spring kümmert sich um die Umwandlung in ein ResponseEntity
mit einem Default-Statuscode.
Manchmal genügt das - siehe unten zum Thema Umgang mit HTTP-Statuscodes.)Der REST-Controller gehört eigentlich zur Infrastruktur-Schicht. In einer hexagonalen oder Onion-Architektur
würde man ihn in den äußeren der konzentrischen Ring platzieren. Nach unseren Prinzipien, die zwei Schichten
explizit macht, passt der REST-Controller eher zum application
-Package. Er ist die Schnittstelle zwischen
dem Client und der Anwendung, und “orchestriert” den Zugriff auf die Anwendungsservices.
Zum API gehören auch die DTOs. Die DTOs sind ja speziell für die REST-API definiert und enthalten nur die
Daten, die der Client benötigt. Sie sind also auch Teil des application
-Package.
GET-Endpoints kann man durch @GetMapping
annotieren.
URIs mit einer ID, wie z.B. /students/{studentId}
, können in der Methode mit der Annotation @PathVariable
verwendet werden. Spring kümmert sich um die passende Typkonvertierung.
@GetMapping("/students/{studentId}")
public StudentDTO getStudentById( @PathVariable UUID studentId ) {
Student student = studentApplicationService.findStudent( studentId );
ModelMapper modelMapper = new ModelMapper();
return modelMapper.map( student, StudentDTO.class );
}
(siehe StudentRESTController.java)
Wenn man die obige Implementation mit einer ungültigen UUID aufruft (z.B. /students/12345
, dann wird ein
allgemeiner Error 400
(Bad Request) zurückgegeben. Das ist nicht immer erwünscht. Eigentlich will man
ja vielleicht einen 404
(Not Found) zurückgeben. In diesem Fall kann man die automatische Spring-Typkonvertierung
umgehen und die ID selbst konvertieren, um dann den entsprechenden Statuscode zurückzugeben.
//@GetMapping("/students/{studentId}")
public ResponseEntity<StudentDTO> getStudentByIdWithProper404( @PathVariable String studentIdAsString ) {
UUID studentId;
try {
studentId = UUID.fromString( studentIdAsString );
} catch ( IllegalArgumentException e ) {
return new ResponseEntity<>( HttpStatus.NOT_FOUND );
}
Student student = studentApplicationService.findStudent( studentId );
ModelMapper modelMapper = new ModelMapper();
StudentDTO studentDTO = modelMapper.map( student, StudentDTO.class );
return new ResponseEntity<>( studentDTO, HttpStatus.OK );
}
(siehe StudentRESTController.java)
Wenn man Query-Parameter in der URI verwenden möchte, so wie z.B. /students?name=...
, dann kann
man das in der Methode mit der Annotation @RequestParam
machen.
/**
* Version with a request parameter
*/
//@GetMapping("/students")
public ResponseEntity<List<StudentDTO>> getAllStudentsFilteredByRequestParameter(
@RequestParam(name = "name") String studentName ) {
//...
}
(siehe StudentRESTController.java)
Aber Achtung: Die Basis-URI, nämlich /students
, ist die gleiche wie in der Methode getAllStudents()
.
Diese kann man nur einmal im Controller definieren. Daher ist die Annotation hier auch auskommentiert.
Wenn sowohl /students
wie auch /students?name=...
aufrufbar sein sollen, dann muss man in der
Controller-Methode eine entsprechende Fallunterscheidung machen, ob der der Query-Parameter gesetzt ist oder nicht.
… funktionieren ähnlich wie GET-Endpoints.
@DeleteMapping("/students/{studentId}")
public ResponseEntity<Void> deleteStudentById( @PathVariable UUID studentId ) {
studentApplicationService.deleteStudent( studentId );
return new ResponseEntity<>( HttpStatus.NO_CONTENT );
}
(siehe StudentRESTController.java)
Statt dem Return-Code 204
(No Content) kann man der Einheitlichkeit halber auch 200
(OK) zurückgeben.
Bei POST-Endpoints wird der Request-Body mit der Annotation @RequestBody
in ein Objekt umgewandelt.
@PostMapping("/internships")
public ResponseEntity createNewInternship( @RequestBody InternshipDTO internshipDTO ) {
InternshipDTO processedDTO = internshipApplicationService.createFromDTO( internshipDTO );
URI returnURI = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand( processedDTO.getId() )
.toUri();
return ResponseEntity
.created(returnURI)
.body( processedDTO );
}
(siehe InternshipRESTController.java)
Es gibt eine Anzahl Caveats (zu beachtende Dinge):
createFromDTO
. Das ist eine Möglichkeit, die Logik in den Application-Service zu verlagern.
Ich würde das vermutlich heute nicht mehr so machen, sondern die Konvertierung in den Controller verlagern.
Möglicherweise mit einer Hilfsklasse, einem eigenen ModelMapper oder einem DTO-Converter, oder in einem
DTO-Konstruktor (siehe siehe unten).400
(Bad Request) durch Spring zurückgegeben. Wenn man stattdessen
den passenderen Code 422
(Unprocessable Entity) zurückgeben will, dann muss man Spring gesondert
konfigurieren, oder die Konvertierung selbst machen.Die Rückgabe der neuen URI im Header ist eine gute Praxis. Der Client kann dann direkt auf die neue
Ressource zugreifen. Die Methode ServletUriComponentsBuilder.fromCurrentRequest()
erzeugt die URI,
die dann mit created(returnURI)
zurückgegeben wird.
Auf PUT-Endpoints gehe ich hier nicht gesondert ein; sie funktionieren wie eine Mischung aus GET- und POST-Endpoints.
PATCH-Endpoints sind eine spezielle Form von PUT-Endpoints, bei denen nur ein Teil der Ressource geändert wird. Auch darauf gehe ich hier nicht gesondert ein.
Die Konvertierung von Entity-Objekten in DTOs und umgekehrt kann manuell erfolgen, oder mit einem
Mapper-Tool wie dem ModelMapper
.
public StudentDTO mapStudentToDTO( Student student ) {
ModelMapper modelMapper = new ModelMapper();
return modelMapper.map( student, StudentDTO );
}
Alternativ kann man z.B. auch eine Factory-Methode in den DTOs verwenden, die die Konvertierung übernimmt. Das ist immer dann sinnvoll, wenn das DTO deutlich vom Entity abweicht, und der ModelMapper nicht alle Konvertierungen automatisch machen kann.
public static StudentDTO fromEntity( Student student ) {
StudentDTO studentDTO = new StudentDTO();
studentDTO.setId( student.getId() );
studentDTO.setName( student.getName() );
studentDTO.setDateOfBirth( student.getDateOfBirth() );
return studentDTO;
}
(siehe StudentDTO.java)
Es gibt im Wesentlichen zwei Wege, passende HTTP-Statuscodes zurückzugeben.
//...
UUID studentId;
try {
studentId = UUID.fromString( studentIdAsString );
} catch ( IllegalArgumentException e ) {
return new ResponseEntity<>( HttpStatus.NOT_FOUND );
}
//...
@ResponseStatus
(siehe unten)Der erste Weg konzentriert die Fehlerbehandlung in der Controller-Methode, was im Sinne von “Single Responsibility Principle” eine gute Idee ist. Allerdings werden dadurch die Controller-Methoden recht groß und aufgebläht.
Der zweite Weg ist da eleganter, aber erfordert unter Umständen ein gewisses Refactoring, falls man sich noch keine großen Gedanken um dedizierte Exceptions gemacht hat.
@ResponseStatus
annotierte ExceptionsWenn man eine mit @ResponseStatus
annotierte Exception in der Geschäftlogik wirft, dann
wird diese durch das Spring-Framework gefangen und der entsprechende HTTP-Returncode
zurückgegeben. Das ist eine elegante Möglichkeit, die Fehlerbehandlung lokal in der
Geschäftlogik zu halten, statt alles noch einmal im Controller zu wiederholen.
@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "Student not found")
public class NonExistentStudentException extends IMSAbstractException {
public NonExistentStudentException( String message ) {
super( message );
}
}
(siehe NonExistentStudentException.java)
Dafür braucht man allerdings dedizierte Exceptions, um diese sinnvoll auf Fehlercodes abbilden zu können. Wenn man z.B. die folgenden hauptsächlichen Fehlerfälle abdecken möchte, dann sollte man für jeden dieser Fälle mindestens eine dedizierte Exception definiert haben.
404
(Not Found): Über das REST-API wird eine Resource angefragt, die nicht existiert422
(Unprocessable Entity): Der Request ist syntaktisch oder semantisch fehlerhaft.
Insbesondere kann das für die Validierung eines Request Body gelten, im Falle eines
POST-, PUT- oder PATCH-Request, z.B. durch fehlende Pflichtfelder oder Typkonflikte409
(Conflict): Die angefragte Ressource existiert und der Request ist auch
syntaktisch korrekt, aber die Aktion kann nicht ausgeführt werden, weil sie einen
Konflikt mit einer anderen Aktion erzeugen würde. Das kann z.B. der Verstoß gegen
eine Integritätsbedingung oder eine Aggregate-Invariante sein.