Kontakt
stefan.bente[at]th-koeln.de
+49 2261 8196 6367
Discord Server
Prof. Bente Personal Zoom
Adresse
Steinmüllerallee 4
51643 Gummersbach
Gebäude LC4
Raum 1708 (Wegbeschreibung)
Sprechstunde nach Vereinbarung
Terminanfrage: calendly.com Wenn Sie dieses Tool nicht nutzen wollen, schicken Sie eine Mail und ich weise Ihnen einen Termin zu.

Entwicklung von REST-APIs mit Spring Boot

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.

Video(s) hierzu

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.)

Der REST-Controller

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.

Prinzipien für die Implementierung von REST-Endpoint-Methoden

An dem obigen Code-Beispiel sieht man noch einige weitere Prinzipien, denen
REST-Endpoint-Methoden folgen sollten.

  1. Entities vs. DTOs: Die Methoden sollten keine Entity-Objekte zurückgeben, sondern DTOs (Data Transfer Objects). Diese DTOs sind speziell für die REST-API definiert und enthalten nur die Daten, die der Client benötigt. Die Konvertierung von Datenmodell-Objekten in DTOs kann z.B. mit dem ModelMapper erfolgen. Damit wird auch die Kopplung zwischen Datenmodell und REST-API reduziert. Man behält in der eigenen Kontrolle, was von dem Datenmodell nach außen sichtbar ist.
  2. Man kann zum Mapping zwischen Entity-Objekten und DTOs den ModelMapper verwenden. Der schaut nach den DTO-Properties und versucht diese nach Namen zu matchen. Alternativ kann man eigene Mapper schreiben - siehe unten.
  3. 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.)
  4. Eine REST-Controller-Methode ist schlank. Sie enthält nur die Umwandlung Request-Daten in passende Formate. Dann folgt ein Aufruf eines Application-Services, der die eigentliche Logik enthält. Die REST-Controller-Methode wandelt das dann wieder um in ein passendes Response-Format, und kümmert sich um Fehlerbehandlung.

Zu welcher Schicht gehören REST-Controller und DTOs?

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

GET-Endpoints kann man durch @GetMapping annotieren.

Umgang mit IDs

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)

Möglicherweise ungewollter Returncode bei ungültiger ID

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)

Query-Parameter

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.

DELETE-Endpoints

… 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.

POST-Endpoints

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):

  • In diesem Code-Beispiel wird die Konvertierung von DTO zu Entity-Objekt im Application Service gemacht, in der Methode 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).
  • Spring konvertiert den Request-Body wieder automatisch in das DTO-Objekt. Das ist sehr praktisch, aber hat wieder wie oben beim Query-Parameter den Nachteil, dass bei ungültigen Daten nur ein allgemeiner Fehlercode 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.

Rückgabe der neuen URI im Header

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.

PUT- und PATCH-Endpoints

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.

Mapping zwischen Entity-Objekten und DTOs

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)

Umgang mit HTTP-Statuscodes

Es gibt im Wesentlichen zwei Wege, passende HTTP-Statuscodes zurückzugeben.

  1. Try-Catch-Block in der Controller-Methode
         //...
         UUID studentId;
         try {
             studentId = UUID.fromString( studentIdAsString );
         } catch ( IllegalArgumentException e ) {
             return new ResponseEntity<>( HttpStatus.NOT_FOUND );
         }
         //...
    
  2. Nutzung dedizierter Exceptions und Annotation mit @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.

Mit @ResponseStatus annotierte Exceptions

Wenn 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 existiert
  • 422 (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 Typkonflikte
  • 409 (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.