03.12.2013
Spring MVC: Ein minimales REST-Beispiel
Der folgende Artikel zeigt, wie man mit Spring MVC eine REST-Schnittstelle implementiert, die die Requests GET, PUT, POST und DELETE unterstützt. Es soll kein Tutorial und auch keine Einführung in Spring MVC sein, es zeigt einfach sehr kompakt, wie sich ein REST-Webservice aufsetzen läßt, der als Austauschformat JSON verwendet. Zusätzlich zu den Requests wird gezeigt, wie Exception Handler mit der Annotation @ExceptionHandler
registriert werden können, so dass auf eine elegante Art die richtigen HTTP-Status-Codes im Fehlerfall zurückgeliefert werden können.
In dem Beispiel werden Objekt vom Typ Customer
mit einem POST angelegt. Dann werden Werte des Customer
mittels eines PUT gesetzt um anschließend alle oder einzelne Customer
mit GET abzufragen. Um das Beispiel einfach zu halten, werden die Customer
einfach in einer Map
abgelegt und nicht in einer Datenbank gespeichert.
Voraussetzungen
Ich gehe im folgenden davon aus, dass Eclipse mit der Spring IDE (STS), dem Maven Plugin (m2e) und Eclipse Web Tools Plattform (WTP) installiert sind. Außerdem wird ein Tomcat benötigt, um das Beispiel zu deployen.
Projekt anlegen
Die Spring Tool Suite bietet eine einfache Möglichkeit um das MVC-Gerüst anzulegen. Dazu wird mit cmd+N ein neues Spring-Projekt angelegt.
Als Template wird Spring MVC Project ausgewählt.
Dann muss ein Packagename vergeben werden.
Hinweis
Der hintere Teil des Package wird dazu verwendet, die URL des REST-Service zu definieren. In dem Beispiel ist das spring, d.h. später ist der REST-Service unter der URL http://localhost:2001/spring/ zu erreichen. (Die Portnummer 2001 hängt von der Konfiguration des Tomcat ab.)
Das erstellte Spring-Projekt sieht dann folgendermaßen aus:
Da wir einen CustomerController
anlegen wollen, sollte die Klasse HomeController
umbenannt werden und der Inhalt der Klasse soweit gelöscht werden, dass sie folgendermaßen aussieht:
package org.hameister.spring; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Controller; @Controller public class CustomerController { private static final Logger logger = LoggerFactory.getLogger(CustomerController.class); }
Maven und Jackson
Damit die JSON-Konvertierung der Customer
-Objekte, die über die REST-Schnittstelle von Spring MVC laufen, richtig vonstatten geht, muss folgende Maven-Dependency
in der pom.xml
ergänzt werden. (Wobei die Versionsnummer von Jackson abweichen kann.)
<dependency> <groupId>org.codehaus.jackson</groupId> <artifactId>jackson-jaxrs</artifactId> <version>1.9.13</version> </dependency>
Wenn die Abhängigkeit definiert wurde, findet Spring MVC Jackson automatisch und wandelt die Objekte nach JSON um.
Customer
In dem Beispiel sollen Kunden (Customer
) angelegt, verändert, abgefragt und gelöscht werden. Deshalb benötigen wir eine einfache Bean-Klasse Customer
, in der eine Id (id
), ein Name (name
) und ein Erstellungsdatum (created
) gespeichert werden.
Die Customer
-Klasse sieht folgendermaßen aus:
package org.hameister.spring; import java.util.Date; public class Customer { private String id; private String name; private Date created; public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Date getCreated() { return created; } public void setCreated(Date created) { this.created = created; } }
MVC-Controller
Nun wird der CustomerController
mit Leben gefüllt.
Damit der REST-Service unter der URL http://[HOSTNAME]/spring/customers
erreichbar ist, wird ein
... @Controller @RequestMapping("/customers") public class CustomerController { ...
Wie oben schon angedeutet, werden die Customer
nicht in einer Datenbank, sondern in einer einfachen Map
abgespeichert. Der eindeutige Identifier id
des Customer
wird mit einem int-Zähler realisiert, der bei jedem neuen Customer
um eins erhört wird.
private Map<String, Customer> customers = new HashMap<String, Customer>(); private long customerIdCounter = 1;
Einen Kunden mit POST anlegen
Beim POST
wird nur ein "leere" Hülle (Resource) für den Kunden angelegt. Der Grund dafür ist, dass POST
weder sicher (safe) noch idempotent ist.
Das bedeutet, dass POST
den Zustand des Servers ggf. ändert und damit nicht wiederholbar ist. Man stelle sich vor, dass ein Kunde bei dem POST
angelegt und gleichzeitig der name
gesetzt werden soll. Wenn bei der Aktion ein Fehler auftritt, kann es sein, dass der Client eventuell nicht weiß in welchem Zustand der Server ist. D.h. wurde der Kunde angelegt oder nicht? Wenn der POST
wiederholt wird, dann wird eventuell ein zweiter Kunden mit den gleichen Daten angelegt.
Deshalb ist es sicherer nur eine Resource anzulegen und dem Client ein HTTP-201 (Created) zurückzuliefern. Die URL unter, der der Kunde zu finden ist, wird im Header unter Location gesetzt.
@RequestMapping(method = RequestMethod.POST, value = "") public ResponseEntity<Void> createCustomer() { Customer customer = new Customer(); customer.setId(String.valueOf(customerIdCounter++)); customers.put(customer.getId(), customer); HttpHeaders headers = new HttpHeaders(); try { headers.setLocation(new URI("/customers/" + customer.getId())); } catch (URISyntaxException e) { logger.error("Invalid URI. Customer id is not valid.", e); } logger.info("Created customer:" + customer.getId()); return new ResponseEntity<Void>(headers, HttpStatus.CREATED); }
Anmerkung: Das Setzen der Location kann mit dem Framework Spring HATEOAS eleganter und "RESTkonformer" gelöst werden.
Einen Kunden mit GET suchen
Mit der Methode GET
werden Kunden zurück an den Client geliefert. Beispielsweise kann nach einem POST
überprüft werden, ob die Resource angelegt wurde. In der Response befindet sich der Customer
und der Status-Code HTTP-200, wenn der Kunde gefunden wurde. (z.B. auf der Resource http://localhost:2001/spring/customers/1
)
@RequestMapping(method = RequestMethod.GET, value = "{customerId}", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<Customer> getCustomer(@PathVariable String customerId) throws CustomerNotFoundException { logger.info("Requested customer with id " + customerId); return new ResponseEntity<Customer>(findCustomer(customerId), HttpStatus.OK); }
Als Hilfsfunktion wird die Methode findCustomer()
verwendet, die eine CustomerNotFoundException
wirft, wenn der Kunde nicht gefunden wurde:
private Customer findCustomer(String customerId) throws CustomerNotFoundException { if (customers.containsKey(customerId)) { return customers.get(customerId); } throw new CustomerNotFoundException(customerId); }
Hinweis: Die Exceptions werden weiter unten behandelt.
Einen Kunden mit PUT ändern
Wie im vorletzten Absatz beschrieben wird beim POST
nur eine Resource angelegt. Mit dem PUT
wird der Kunde mit Daten befüllt. In dem Beispiel wird einfach ein Name gesetzt. (z.B. auf der Resource http://localhost:2001/spring/customers/1
)
Bei der Methode PUT
handelt es sich um eine idempotente Methode, d.h. wenn man sie mehrfach aufruft, sollte sie den gleichen Effekt haben. Erhält der Client beispielsweise nach einem PUT
keine Antwort, dann kann er den Request problemlos nochmal anschicken, bis er den Stautscode HTTP-204 erhält. (Ohne, wie beim POST
neue Resourcen anzulegen.)
Der Statuscode 204 (NO_CONTENT) gedeutet übrigens, dass die Method keinen Inhalt zurückliefert. D.h. es muss nicht immer ein Statuscode 200 (OK) an den Client gesendet werden.
@RequestMapping(method = RequestMethod.PUT, value = "{customerId}", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<Void> updateCustomer(@PathVariable String customerId, @RequestBody Map<String, String> customerData) throws CustomerNotFoundException, MandatoryArgumentMissingException { logger.info("Try to update the Customer: " + customerId); Customer customer = findCustomer(customerId); if (customerData.containsKey("Name")) { customer.setName(customerData.get("Name")); } else { throw new MandatoryArgumentMissingException("Request does not contain the key 'Name'"); } customer.setCreated(new Date()); return new ResponseEntity<Void>(HttpStatus.NO_CONTENT); }
HTML und POST/PUT
Wer nun einwedet, dass HTML nur POST
aber kein PUT unterstützt und er deshalb mit dem initialen POST
den Kunden inklusive aller Daten anlegen muss, dem kann auch geholfen werden. Es gibt die Möglichkeit ein verstecktes INPUT
-Feld in HTML zu definieren und ein PUT
auszulösen.
<input type='hidden' name='_method' value='PUT'>
Natürlich wird immer noch ein POST gesendet, allerdings wertet der Server den zusätzlichen Parameter aus und interpretiert den Request als PUT
. Auch Spring MVC unterstützt diese Funktionalität durch die Klasse HiddenHttpMethodFilter. Mit DELETE
kann genauso verfahren werden. Und auch beim Einsatz von Ajax, beispielsweise mit JQuery, kann man so vorgehen.
Einen Kunden mit DELETE löschen
Auch bei DELETE
handelt es sich um eine idempotente Methode. Wenn ich DELETE
mehrfach auf einer Resource aufrufe, sollte es den gleichen Effekt haben. (Wenn der Kunde schon gelöscht ist, passiert halt nichts.)
@RequestMapping(method = RequestMethod.DELETE, value = "{customerId}") public ResponseEntity<Void> deleteCustomer(@PathVariable String customerId) throws CustomerNotFoundException { logger.info("Try to delete the Customer: " + customerId); customers.remove(findCustomer(customerId).getId()); logger.info("Deleted Customer:" + customerId); return new ResponseEntity<Void>(HttpStatus.OK); }
Eine Liste mit allen Kunden mit GET anfragen
Um eine Liste mit allen Kunden zu erhalten, wird, wie schon beim Anlegen eines Kunden mit POST
, keine Resource mitgegeben. (z.B. auf der Resource http://localhost:2001/spring/customers/
)
@RequestMapping(method=RequestMethod.GET, value="", produces=MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<List<Customer>> allCustomers() { return new ResponseEntity<List<Customer>>(new ArrayList<Customer>(customers.values()), HttpStatus.OK); }
Exception Handler
In den Methoden weiter oben wurden verschiedene Exceptions geworfen. Beispielsweise CustomerNotFoundException
und MandatoryArgumentMissingException
. Mit diesen Exceptions kann ein Client natürlich nichts anfangen. Deshalb bietet Spring MVC die Möglichkeit ExceptionHandler
zu definieren.
@ExceptionHandler(CustomerNotFoundException.class) public ResponseEntity<String> handleCustomerNotFound(Exception e) { return new ResponseEntity<String>("{\"reason\":\"" + e.getMessage() + "\"}", HttpStatus.NOT_FOUND); } @ExceptionHandler({ MandatoryArgumentMissingException.class, IllegalArgumentException.class }) public ResponseEntity<String> handleBadRequest(Exception e) { return new ResponseEntity<String>("{\"reason\":\"" + e.getMessage() + "\"}", HttpStatus.BAD_REQUEST); }
Wird beispielsweise ein Kunde nicht gefunden und eine CustomerNotFoundException
wird geworfen, dann erhält der Client einen HTTP-404 und die Fehlermeldung aus der Exception.
Falls die Argumente des Requests nicht richtig sind (siehe PUT-Methode updateCustomer
), dann wird ein HTTP-400 an den Client zurückgeliefert.
Die Exception CustomerNotFoundException
sieht folgendermaßen aus:
package org.hameister.spring; public class CustomerNotFoundException extends Exception { private static final long serialVersionUID = 1L; private static final String MESSAGE_FORMAT = "Customer with id '%s' not found."; public CustomerNotFoundException(String customerId) { super(String.format(MESSAGE_FORMAT, customerId)); } }
Auch die Exception MandatoryArgumentMissingException
sieht nicht viel anders aus. Hauptzweck dieser Exceptions ist die "schöne" Fehlermeldung, die an den Client zurückgegeben wird:
package org.hameister.spring; public class MandatoryArgumentMissingException extends Exception { private static final long serialVersionUID = 1L; private static final String MESSAGE_FORMAT = "The mandatory argument '%s' is missing in the request."; public MandatoryArgumentMissingException(String customerId) { super(String.format(MESSAGE_FORMAT, customerId)); } }
Der komplette CustomerController
Die komplette Klasse CustomerController
mit dem Spring MVC-Controller sieht folgendermaßen aus:
package org.hameister.spring; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @Controller @RequestMapping("/customers") public class CustomerController { private static final Logger logger = LoggerFactory.getLogger(CustomerController.class); private Map<String, Customer> customers = new HashMap<String, Customer>(); private long customerIdCounter = 1; @RequestMapping(method=RequestMethod.GET, value="", produces=MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<List<Customer>> allCustomers() { return new ResponseEntity<List<Customer>>(new ArrayList<Customer>(customers.values()), HttpStatus.OK); } @RequestMapping(method = RequestMethod.POST, value = "") public ResponseEntity<Void> createCustomer() { Customer customer = new Customer(); customer.setId(String.valueOf(customerIdCounter++)); customers.put(customer.getId(), customer); HttpHeaders headers = new HttpHeaders(); try { headers.setLocation(new URI("/customers/" + customer.getId())); } catch (URISyntaxException e) { logger.error("Invalid URI. Customer id is not valid.", e); } logger.info("Created customer:" + customer.getId()); return new ResponseEntity<Void>(headers, HttpStatus.CREATED); } @RequestMapping(method = RequestMethod.GET, value = "{customerId}", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<Customer> getCustomer(@PathVariable String customerId) throws CustomerNotFoundException { logger.info("Requested customer with id " + customerId); return new ResponseEntity<Customer>(findCustomer(customerId), HttpStatus.OK); } @RequestMapping(method = RequestMethod.PUT, value = "{customerId}", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<Void> updateCustomer(@PathVariable String customerId, @RequestBody Map<String, String> customerData) throws CustomerNotFoundException, MandatoryArgumentMissingException { logger.info("Try to update the Customer: " + customerId); Customer customer = findCustomer(customerId); if (customerData.containsKey("Name")) { customer.setName(customerData.get("Name")); } else { throw new MandatoryArgumentMissingException("Request does not contain the key 'Name'"); } customer.setCreated(new Date()); return new ResponseEntity<Void>(HttpStatus.NO_CONTENT); } @RequestMapping(method = RequestMethod.DELETE, value = "{customerId}") public ResponseEntity<Void> deleteCustomer(@PathVariable String customerId) throws CustomerNotFoundException { logger.info("Try to delete the Customer: " + customerId); customers.remove(findCustomer(customerId).getId()); logger.info("Deleted Customer:" + customerId); return new ResponseEntity<Void>(HttpStatus.OK); } private Customer findCustomer(String customerId) throws CustomerNotFoundException { if (customers.containsKey(customerId)) { return customers.get(customerId); } throw new CustomerNotFoundException(customerId); } @ExceptionHandler(CustomerNotFoundException.class) public ResponseEntity<String> handleCustomerNotFound(Exception e) { return new ResponseEntity<String>("{\"reason\":\"" + e.getMessage() + "\"}", HttpStatus.NOT_FOUND); } @ExceptionHandler({ MandatoryArgumentMissingException.class, IllegalArgumentException.class }) public ResponseEntity<String> handleBadRequest(Exception e) { return new ResponseEntity<String>("{\"reason\":\"" + e.getMessage() + "\"}", HttpStatus.BAD_REQUEST); } }
Test der REST-Schnittstelle mit cURL
Nachdem die war
-Datei im Tomcat deployed wurde, kann die REST-Schnittstelle beispielsweise mit dem Tool cURL ausprobiert werden.
Anlegen eines Customer:
curl -i -X POST -H "Content-Type: application/json" http://localhost:2001/spring/customers/
Aktualisieren eines Customer:
curl -i -X PUT -d '{"Name":"Max"}' -H "Content-Type: application/json" http://localhost:2001/spring/customers/1
Abfrage eines Customer:
curl -i http://localhost:2001/spring/customers/1
Löschen eines Customer:
curl -i -X DELETE -H "Content-Type: application/json" http://localhost:2001/spring/customers/1
Abfrage aller Customer:
curl -i http://localhost:2001/spring/customers
In dem Artikel Testen eine REST-Schnittstelle mit REST Assured habe ich beschrieben, wie die REST-Schnittstelle mit dem Framework REST Assured getestet werden kann.
Beispielcode
Der komplette Quellcode kann bei GitHub unter URL https://github.com/hameister/CustomerService heruntergeladen werden.
Dieser Stand kann mit git checkout 5eae4d91404db660bca409e2a9feed01d0f02a76
ausgecheckt werden.
Anmerkungen
Das cURL-Tool ist zwar ein praktisches Werkzeug, um schnell festzustellen, ob der REST-Service verfügbar ist und wie erwartet reagiert, aber für automatisierte Tests ist es nur bedingt geeignet. Eine Möglichkeit um automatisiert REST-Schnittstellen zu testen, ist das Framework REST Assured. In dem Artikel Testen einer REST-Schnittstelle mit REST Assured habe ich den hier beschriebenen REST-Service mit REST Assured getestet.