Spring MVC: Ein minimales REST-Beispiel mit JSON (GET, POST, PUT, DELETE)

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 RequestMapping für die Klasse definiert:

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