├── .gitignore ├── README.md ├── example ├── pom.xml └── src │ └── main │ ├── java │ └── com │ │ └── stormpath │ │ └── blog │ │ └── spring │ │ ├── http │ │ └── converter │ │ │ └── json │ │ │ └── DefaultJacksonHttpMessageConverter.java │ │ └── mvc │ │ └── rest │ │ └── exhandler │ │ ├── DefaultController.java │ │ ├── UnknownResourceException.java │ │ ├── User.java │ │ └── UserController.java │ └── webapp │ └── WEB-INF │ ├── rest-servlet.xml │ └── web.xml ├── main ├── pom.xml └── src │ └── main │ └── java │ └── com │ └── stormpath │ └── spring │ └── web │ └── servlet │ └── handler │ ├── DefaultRestErrorResolver.java │ ├── MapRestErrorConverter.java │ ├── RestError.java │ ├── RestErrorConverter.java │ ├── RestErrorResolver.java │ └── RestExceptionHandler.java └── pom.xml /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.ipr 3 | *.iws 4 | target -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #Stormpath is Joining Okta 2 | We are incredibly excited to announce that [Stormpath is joining forces with Okta](https://stormpath.com/blog/stormpaths-new-path?utm_source=github&utm_medium=readme&utm-campaign=okta-announcement). Please visit [the Migration FAQs](https://stormpath.com/oktaplusstormpath?utm_source=github&utm_medium=readme&utm-campaign=okta-announcement) for a detailed look at what this means for Stormpath users. 3 | 4 | We're available to answer all questions at [support@stormpath.com](mailto:support@stormpath.com). 5 | 6 | spring-mvc-rest-exhandler 7 | ========================= 8 | 9 | Spring MVC ReST Exception Handler 10 | 11 | Check out the two-part blog post that this example backs: [Part 1](https://stormpath.com/blog/spring-mvc-rest-exception-handling-best-practices-part-1/), [Part 2](https://stormpath.com/blog/spring-mvc-rest-exception-handling-best-practices-part-2/) 12 | -------------------------------------------------------------------------------- /example/pom.xml: -------------------------------------------------------------------------------- 1 | 16 | 18 | 19 | 4.0.0 20 | 21 | 22 | com.stormpath.blog 23 | spring-mvc-rest-exhandler-root 24 | 1.0.0-SNAPSHOT 25 | 26 | 27 | com.stormpath.blog 28 | spring-mvc-rest-exhandler-example 29 | 1.0.0-SNAPSHOT 30 | war 31 | 32 | Spring MVC Rest Exception Handler : Example Webapp 33 | 34 | 35 | 36 | com.stormpath.blog 37 | spring-mvc-rest-exhandler 38 | 39 | 40 | org.codehaus.jackson 41 | jackson-mapper-asl 42 | 43 | 44 | org.slf4j 45 | slf4j-api 46 | 47 | 48 | org.springframework 49 | spring-web 50 | 51 | 52 | org.springframework 53 | spring-webmvc 54 | 55 | 56 | javax.servlet 57 | servlet-api 58 | 59 | 60 | 61 | 62 | 63 | 64 | org.mortbay.jetty 65 | maven-jetty-plugin 66 | ${jetty.version} 67 | 68 | / 69 | 70 | 71 | 8080 72 | 60000 73 | 74 | 75 | 76 | ./target/yyyy_mm_dd.request.log 77 | 90 78 | true 79 | false 80 | GMT 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /example/src/main/java/com/stormpath/blog/spring/http/converter/json/DefaultJacksonHttpMessageConverter.java: -------------------------------------------------------------------------------- 1 | package com.stormpath.blog.spring.http.converter.json; 2 | 3 | import org.codehaus.jackson.JsonEncoding; 4 | import org.codehaus.jackson.JsonGenerationException; 5 | import org.codehaus.jackson.JsonGenerator; 6 | import org.codehaus.jackson.JsonParseException; 7 | import org.codehaus.jackson.map.ObjectMapper; 8 | import org.codehaus.jackson.map.type.TypeFactory; 9 | import org.codehaus.jackson.type.JavaType; 10 | import org.springframework.http.HttpInputMessage; 11 | import org.springframework.http.HttpOutputMessage; 12 | import org.springframework.http.MediaType; 13 | import org.springframework.http.converter.AbstractHttpMessageConverter; 14 | import org.springframework.http.converter.HttpMessageNotReadableException; 15 | import org.springframework.http.converter.HttpMessageNotWritableException; 16 | import org.springframework.util.Assert; 17 | 18 | import java.io.IOException; 19 | import java.nio.charset.Charset; 20 | 21 | /** 22 | * Replaces Spring's {@link org.springframework.http.converter.json.MappingJacksonHttpMessageConverter}, which is 23 | * difficult to configure for pretty-printing. This implementation enables pretty-printing easily via a setter/getter. 24 | *

25 | * See 26 | * When using Spring MVC for REST, how do you enable Jackson to pretty-print rendered JSON? and the latest 27 | * Spring Framework incarnation supporting pretty printing 28 | * (not yet released at the time of writing). 29 | * 30 | * @author Les Hazlewood 31 | */ 32 | public class DefaultJacksonHttpMessageConverter extends AbstractHttpMessageConverter { 33 | 34 | public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); 35 | 36 | private ObjectMapper objectMapper = new ObjectMapper(); 37 | private boolean prefixJson = false; 38 | private boolean prettyPrint = false; 39 | 40 | /** 41 | * Construct a new {@code DefaultJacksonHttpMessageConverter}. 42 | */ 43 | public DefaultJacksonHttpMessageConverter() { 44 | super(new MediaType("application", "json", DEFAULT_CHARSET)); 45 | } 46 | 47 | @Override 48 | public boolean canRead(Class clazz, MediaType mediaType) { 49 | JavaType javaType = getJavaType(clazz); 50 | return objectMapper.canDeserialize(javaType) && canRead(mediaType); 51 | } 52 | 53 | @Override 54 | public boolean canWrite(Class clazz, MediaType mediaType) { 55 | return objectMapper.canSerialize(clazz) && canWrite(mediaType); 56 | } 57 | 58 | /** 59 | * Returns the Jackson {@link JavaType} for the specific class. 60 | *

61 | *

Default implementation returns {@link org.codehaus.jackson.map.type.TypeFactory#type(java.lang.reflect.Type)}, but this can be overridden 62 | * in subclasses, to allow for custom generic collection handling. For instance: 63 | *

 64 |      * protected JavaType getJavaType(Class<?> clazz) {
 65 |      * if (List.class.isAssignableFrom(clazz)) {
 66 |      * return TypeFactory.collectionType(ArrayList.class, MyBean.class);
 67 |      * } else {
 68 |      * return super.getJavaType(clazz);
 69 |      * }
 70 |      * }
 71 |      * 
72 | * 73 | * @param clazz the class to return the java type for 74 | * @return the java type 75 | */ 76 | protected JavaType getJavaType(Class clazz) { 77 | return TypeFactory.type(clazz); 78 | } 79 | 80 | @Override 81 | protected Object readInternal(Class clazz, HttpInputMessage inputMessage) 82 | throws IOException, HttpMessageNotReadableException { 83 | JavaType javaType = getJavaType(clazz); 84 | try { 85 | return objectMapper.readValue(inputMessage.getBody(), javaType); 86 | } catch (JsonParseException ex) { 87 | throw new HttpMessageNotReadableException("Could not read JSON: " + ex.getMessage(), ex); 88 | } 89 | } 90 | 91 | @Override 92 | protected boolean supports(Class clazz) { 93 | // should not be called, since we override canRead/Write instead 94 | throw new UnsupportedOperationException(); 95 | } 96 | 97 | @Override 98 | protected void writeInternal(Object o, HttpOutputMessage outputMessage) 99 | throws IOException, HttpMessageNotWritableException { 100 | JsonEncoding encoding = getEncoding(outputMessage.getHeaders().getContentType()); 101 | JsonGenerator jsonGenerator = 102 | getObjectMapper().getJsonFactory().createJsonGenerator(outputMessage.getBody(), encoding); 103 | try { 104 | if (prefixJson) { 105 | jsonGenerator.writeRaw("{} && "); 106 | } 107 | if (isPrettyPrint()) { 108 | jsonGenerator.useDefaultPrettyPrinter(); 109 | } 110 | getObjectMapper().writeValue(jsonGenerator, o); 111 | } catch (JsonGenerationException ex) { 112 | throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex); 113 | } 114 | } 115 | 116 | private JsonEncoding getEncoding(MediaType contentType) { 117 | if (contentType != null && contentType.getCharSet() != null) { 118 | Charset charset = contentType.getCharSet(); 119 | for (JsonEncoding encoding : JsonEncoding.values()) { 120 | if (charset.name().equals(encoding.getJavaName())) { 121 | return encoding; 122 | } 123 | } 124 | } 125 | return JsonEncoding.UTF8; 126 | } 127 | 128 | public ObjectMapper getObjectMapper() { 129 | return objectMapper; 130 | } 131 | 132 | /** 133 | * Sets the {@code ObjectMapper} for this view. If not set, a default 134 | * {@link ObjectMapper#ObjectMapper() ObjectMapper} is used. 135 | *

Setting a custom-configured {@code ObjectMapper} is one way to take further control of the JSON serialization 136 | * process. For example, an extended {@link org.codehaus.jackson.map.SerializerFactory} can be configured that provides 137 | * custom serializers for specific types. The other option for refining the serialization process is to use Jackson's 138 | * provided annotations on the types to be serialized, in which case a custom-configured ObjectMapper is unnecessary. 139 | * 140 | * @param objectMapper - 141 | */ 142 | public void setObjectMapper(ObjectMapper objectMapper) { 143 | Assert.notNull(objectMapper, "'objectMapper' must not be null"); 144 | this.objectMapper = objectMapper; 145 | } 146 | 147 | public boolean isPrettyPrint() { 148 | return prettyPrint; 149 | } 150 | 151 | public void setPrettyPrint(boolean prettyPrint) { 152 | this.prettyPrint = prettyPrint; 153 | } 154 | 155 | /** 156 | * Indicates whether the JSON output by this view should be prefixed with "{} &&". Default is false. 157 | *

Prefixing the JSON string in this manner is used to help prevent JSON Hijacking. The prefix renders the string 158 | * syntactically invalid as a script so that it cannot be hijacked. This prefix does not affect the evaluation of JSON, 159 | * but if JSON validation is performed on the string, the prefix would need to be ignored. 160 | * 161 | * @param prefixJson - 162 | */ 163 | public void setPrefixJson(boolean prefixJson) { 164 | this.prefixJson = prefixJson; 165 | } 166 | } 167 | 168 | -------------------------------------------------------------------------------- /example/src/main/java/com/stormpath/blog/spring/mvc/rest/exhandler/DefaultController.java: -------------------------------------------------------------------------------- 1 | package com.stormpath.blog.spring.mvc.rest.exhandler; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.web.bind.annotation.RequestMapping; 5 | 6 | import javax.servlet.http.HttpServletRequest; 7 | 8 | /** 9 | * Default controller that exists to return a proper REST response for unmapped requests. 10 | */ 11 | @Controller 12 | public class DefaultController { 13 | 14 | @RequestMapping("/**") 15 | public void unmappedRequest(HttpServletRequest request) { 16 | String uri = request.getRequestURI(); 17 | throw new UnknownResourceException("There is no resource for path " + uri); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /example/src/main/java/com/stormpath/blog/spring/mvc/rest/exhandler/UnknownResourceException.java: -------------------------------------------------------------------------------- 1 | package com.stormpath.blog.spring.mvc.rest.exhandler; 2 | 3 | /** 4 | * Simulated business-logic exception indicating a desired business entity or record cannot be found. 5 | */ 6 | public class UnknownResourceException extends RuntimeException { 7 | 8 | public UnknownResourceException(String msg) { 9 | super(msg); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/src/main/java/com/stormpath/blog/spring/mvc/rest/exhandler/User.java: -------------------------------------------------------------------------------- 1 | package com.stormpath.blog.spring.mvc.rest.exhandler; 2 | 3 | /** 4 | * Simple User POJO used in the Spring MVC Rest Exception handling example. 5 | */ 6 | public class User { 7 | 8 | private String name; 9 | private String username; 10 | 11 | public User(){} 12 | 13 | public User(String name, String username) { 14 | this.username = username; 15 | this.name = name; 16 | } 17 | 18 | public String getUsername() { 19 | return username; 20 | } 21 | 22 | public void setUsername(String username) { 23 | this.username = username; 24 | } 25 | 26 | public String getName() { 27 | return name; 28 | } 29 | 30 | public void setName(String name) { 31 | this.name = name; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example/src/main/java/com/stormpath/blog/spring/mvc/rest/exhandler/UserController.java: -------------------------------------------------------------------------------- 1 | package com.stormpath.blog.spring.mvc.rest.exhandler; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.util.StringUtils; 5 | import org.springframework.web.bind.annotation.PathVariable; 6 | import org.springframework.web.bind.annotation.RequestMapping; 7 | import org.springframework.web.bind.annotation.ResponseBody; 8 | 9 | import static org.springframework.web.bind.annotation.RequestMethod.GET; 10 | 11 | /** 12 | * Example Spring MVC Controller that will throw exceptions for specific URLs to show exception handling. 13 | */ 14 | @Controller 15 | @RequestMapping("/users") 16 | public class UserController { 17 | 18 | @RequestMapping(value = "/{username}", method = GET) 19 | @ResponseBody 20 | public User getUser(@PathVariable String username) { 21 | //simulate Manager/DAO call: 22 | return findUser(username); 23 | } 24 | 25 | /** 26 | * Simulates a Manager or DAO lookup for a stored user account. 27 | * 28 | * @param username the username for the user to lookup. Supports only 'jsmith' and 'djones' for testing. 29 | * @return the associated user 30 | * @throws UnknownResourceException if there is no user with the specified username. 31 | */ 32 | private User findUser(String username) throws UnknownResourceException { 33 | if (!StringUtils.hasText(username)) { 34 | throw new IllegalArgumentException("Username is required."); 35 | } 36 | 37 | //simulate a successful lookup for 2 users: 38 | if ("jsmith".equals(username)) { 39 | return new User("Jane Smith", username); 40 | } 41 | if ("djones".equals(username)) { 42 | return new User("Don Jones", username); 43 | } 44 | 45 | //any other lookup throws an exception (not found): 46 | throw new UnknownResourceException("Unable to find user with username '" + username + "'"); 47 | } 48 | 49 | 50 | } 51 | -------------------------------------------------------------------------------- /example/src/main/webapp/WEB-INF/rest-servlet.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 54 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /example/src/main/webapp/WEB-INF/web.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Spring MVC ReST Exception Handling Example WebApp 7 | 8 | 9 | rest 10 | org.springframework.web.servlet.DispatcherServlet 11 | 1 12 | 13 | 14 | 15 | rest 16 | 17 | /v1/* 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /main/pom.xml: -------------------------------------------------------------------------------- 1 | 16 | 18 | 19 | 4.0.0 20 | 21 | 22 | com.stormpath.blog 23 | spring-mvc-rest-exhandler-root 24 | 1.0.0-SNAPSHOT 25 | 26 | 27 | com.stormpath.blog 28 | spring-mvc-rest-exhandler 29 | 1.0.0-SNAPSHOT 30 | jar 31 | 32 | Spring MVC Rest Exception Handler : Main 33 | 34 | 35 | 36 | org.slf4j 37 | slf4j-api 38 | 39 | 40 | org.springframework 41 | spring-web 42 | 43 | 44 | org.springframework 45 | spring-webmvc 46 | 47 | 48 | javax.servlet 49 | servlet-api 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /main/src/main/java/com/stormpath/spring/web/servlet/handler/DefaultRestErrorResolver.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 Stormpath, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.stormpath.spring.web.servlet.handler; 17 | 18 | import org.slf4j.Logger; 19 | import org.slf4j.LoggerFactory; 20 | import org.springframework.beans.TypeMismatchException; 21 | import org.springframework.beans.factory.InitializingBean; 22 | import org.springframework.context.MessageSource; 23 | import org.springframework.context.MessageSourceAware; 24 | import org.springframework.http.HttpStatus; 25 | import org.springframework.http.converter.HttpMessageNotReadableException; 26 | import org.springframework.util.CollectionUtils; 27 | import org.springframework.util.StringUtils; 28 | import org.springframework.web.HttpMediaTypeNotAcceptableException; 29 | import org.springframework.web.HttpMediaTypeNotSupportedException; 30 | import org.springframework.web.HttpRequestMethodNotSupportedException; 31 | import org.springframework.web.bind.MissingServletRequestParameterException; 32 | import org.springframework.web.context.request.ServletWebRequest; 33 | import org.springframework.web.servlet.LocaleResolver; 34 | import org.springframework.web.servlet.mvc.multiaction.NoSuchRequestHandlingMethodException; 35 | 36 | import java.util.*; 37 | 38 | /** 39 | * Default {@code RestErrorResolver} implementation that converts discovered Exceptions to 40 | * {@link RestError} instances. 41 | * 42 | * @author Les Hazlewood 43 | */ 44 | public class DefaultRestErrorResolver implements RestErrorResolver, MessageSourceAware, InitializingBean { 45 | 46 | public static final String DEFAULT_EXCEPTION_MESSAGE_VALUE = "_exmsg"; 47 | public static final String DEFAULT_MESSAGE_VALUE = "_msg"; 48 | 49 | private static final Logger log = LoggerFactory.getLogger(DefaultRestErrorResolver.class); 50 | 51 | private Map exceptionMappings = Collections.emptyMap(); 52 | 53 | private Map exceptionMappingDefinitions = Collections.emptyMap(); 54 | 55 | private MessageSource messageSource; 56 | private LocaleResolver localeResolver; 57 | 58 | private String defaultMoreInfoUrl; 59 | private boolean defaultEmptyCodeToStatus; 60 | private String defaultDeveloperMessage; 61 | 62 | public DefaultRestErrorResolver() { 63 | this.defaultEmptyCodeToStatus = true; 64 | this.defaultDeveloperMessage = DEFAULT_EXCEPTION_MESSAGE_VALUE; 65 | } 66 | 67 | public void setMessageSource(MessageSource messageSource) { 68 | this.messageSource = messageSource; 69 | } 70 | 71 | public void setLocaleResolver(LocaleResolver resolver) { 72 | this.localeResolver = resolver; 73 | } 74 | 75 | public void setExceptionMappingDefinitions(Map exceptionMappingDefinitions) { 76 | this.exceptionMappingDefinitions = exceptionMappingDefinitions; 77 | } 78 | 79 | public void setDefaultMoreInfoUrl(String defaultMoreInfoUrl) { 80 | this.defaultMoreInfoUrl = defaultMoreInfoUrl; 81 | } 82 | 83 | public void setDefaultEmptyCodeToStatus(boolean defaultEmptyCodeToStatus) { 84 | this.defaultEmptyCodeToStatus = defaultEmptyCodeToStatus; 85 | } 86 | 87 | public void setDefaultDeveloperMessage(String defaultDeveloperMessage) { 88 | this.defaultDeveloperMessage = defaultDeveloperMessage; 89 | } 90 | 91 | @Override 92 | public void afterPropertiesSet() throws Exception { 93 | 94 | //populate with some defaults: 95 | Map definitions = createDefaultExceptionMappingDefinitions(); 96 | 97 | //add in user-specified mappings (will override defaults as necessary): 98 | if (this.exceptionMappingDefinitions != null && !this.exceptionMappingDefinitions.isEmpty()) { 99 | definitions.putAll(this.exceptionMappingDefinitions); 100 | } 101 | 102 | this.exceptionMappings = toRestErrors(definitions); 103 | } 104 | 105 | protected final Map createDefaultExceptionMappingDefinitions() { 106 | 107 | Map m = new LinkedHashMap(); 108 | 109 | // 400 110 | applyDef(m, HttpMessageNotReadableException.class, HttpStatus.BAD_REQUEST); 111 | applyDef(m, MissingServletRequestParameterException.class, HttpStatus.BAD_REQUEST); 112 | applyDef(m, TypeMismatchException.class, HttpStatus.BAD_REQUEST); 113 | applyDef(m, "javax.validation.ValidationException", HttpStatus.BAD_REQUEST); 114 | 115 | // 404 116 | applyDef(m, NoSuchRequestHandlingMethodException.class, HttpStatus.NOT_FOUND); 117 | applyDef(m, "org.hibernate.ObjectNotFoundException", HttpStatus.NOT_FOUND); 118 | 119 | // 405 120 | applyDef(m, HttpRequestMethodNotSupportedException.class, HttpStatus.METHOD_NOT_ALLOWED); 121 | 122 | // 406 123 | applyDef(m, HttpMediaTypeNotAcceptableException.class, HttpStatus.NOT_ACCEPTABLE); 124 | 125 | // 409 126 | //can't use the class directly here as it may not be an available dependency: 127 | applyDef(m, "org.springframework.dao.DataIntegrityViolationException", HttpStatus.CONFLICT); 128 | 129 | // 415 130 | applyDef(m, HttpMediaTypeNotSupportedException.class, HttpStatus.UNSUPPORTED_MEDIA_TYPE); 131 | 132 | return m; 133 | } 134 | 135 | private void applyDef(Map m, Class clazz, HttpStatus status) { 136 | applyDef(m, clazz.getName(), status); 137 | } 138 | 139 | private void applyDef(Map m, String key, HttpStatus status) { 140 | m.put(key, definitionFor(status)); 141 | } 142 | 143 | private String definitionFor(HttpStatus status) { 144 | return status.value() + ", " + DEFAULT_EXCEPTION_MESSAGE_VALUE; 145 | } 146 | 147 | @Override 148 | public RestError resolveError(ServletWebRequest request, Object handler, Exception ex) { 149 | 150 | RestError template = getRestErrorTemplate(ex); 151 | if (template == null) { 152 | return null; 153 | } 154 | 155 | RestError.Builder builder = new RestError.Builder(); 156 | builder.setStatus(getStatusValue(template, request, ex)); 157 | builder.setCode(getCode(template, request, ex)); 158 | builder.setMoreInfoUrl(getMoreInfoUrl(template, request, ex)); 159 | builder.setThrowable(ex); 160 | 161 | String msg = getMessage(template, request, ex); 162 | if (msg != null) { 163 | builder.setMessage(msg); 164 | } 165 | msg = getDeveloperMessage(template, request, ex); 166 | if (msg != null) { 167 | builder.setDeveloperMessage(msg); 168 | } 169 | 170 | return builder.build(); 171 | } 172 | 173 | protected int getStatusValue(RestError template, ServletWebRequest request, Exception ex) { 174 | return template.getStatus().value(); 175 | } 176 | 177 | protected int getCode(RestError template, ServletWebRequest request, Exception ex) { 178 | int code = template.getCode(); 179 | if ( code <= 0 && defaultEmptyCodeToStatus) { 180 | code = getStatusValue(template, request, ex); 181 | } 182 | return code; 183 | } 184 | 185 | protected String getMoreInfoUrl(RestError template, ServletWebRequest request, Exception ex) { 186 | String moreInfoUrl = template.getMoreInfoUrl(); 187 | if (moreInfoUrl == null) { 188 | moreInfoUrl = this.defaultMoreInfoUrl; 189 | } 190 | return moreInfoUrl; 191 | } 192 | 193 | protected String getMessage(RestError template, ServletWebRequest request, Exception ex) { 194 | return getMessage(template.getMessage(), request, ex); 195 | } 196 | 197 | protected String getDeveloperMessage(RestError template, ServletWebRequest request, Exception ex) { 198 | String devMsg = template.getDeveloperMessage(); 199 | if (devMsg == null && defaultDeveloperMessage != null) { 200 | devMsg = defaultDeveloperMessage; 201 | } 202 | if (DEFAULT_MESSAGE_VALUE.equals(devMsg)) { 203 | devMsg = template.getMessage(); 204 | } 205 | return getMessage(devMsg, request, ex); 206 | } 207 | 208 | /** 209 | * Returns the response status message to return to the client, or {@code null} if no 210 | * status message should be returned. 211 | * 212 | * @return the response status message to return to the client, or {@code null} if no 213 | * status message should be returned. 214 | */ 215 | protected String getMessage(String msg, ServletWebRequest webRequest, Exception ex) { 216 | 217 | if (msg != null) { 218 | if (msg.equalsIgnoreCase("null") || msg.equalsIgnoreCase("off")) { 219 | return null; 220 | } 221 | if (msg.equalsIgnoreCase(DEFAULT_EXCEPTION_MESSAGE_VALUE)) { 222 | msg = ex.getMessage(); 223 | } 224 | if (messageSource != null) { 225 | Locale locale = null; 226 | if (localeResolver != null) { 227 | locale = localeResolver.resolveLocale(webRequest.getRequest()); 228 | } 229 | msg = messageSource.getMessage(msg, null, msg, locale); 230 | } 231 | } 232 | 233 | return msg; 234 | } 235 | 236 | /** 237 | * Returns the config-time 'template' RestError instance configured for the specified Exception, or 238 | * {@code null} if a match was not found. 239 | *

240 | * The config-time template is used as the basis for the RestError constructed at runtime. 241 | * @param ex 242 | * @return the template to use for the RestError instance to be constructed. 243 | */ 244 | private RestError getRestErrorTemplate(Exception ex) { 245 | Map mappings = this.exceptionMappings; 246 | if (CollectionUtils.isEmpty(mappings)) { 247 | return null; 248 | } 249 | RestError template = null; 250 | String dominantMapping = null; 251 | int deepest = Integer.MAX_VALUE; 252 | for (Map.Entry entry : mappings.entrySet()) { 253 | String key = entry.getKey(); 254 | int depth = getDepth(key, ex); 255 | if (depth >= 0 && depth < deepest) { 256 | deepest = depth; 257 | dominantMapping = key; 258 | template = entry.getValue(); 259 | } 260 | } 261 | if (template != null && log.isDebugEnabled()) { 262 | log.debug("Resolving to RestError template '" + template + "' for exception of type [" + ex.getClass().getName() + 263 | "], based on exception mapping [" + dominantMapping + "]"); 264 | } 265 | return template; 266 | } 267 | 268 | /** 269 | * Return the depth to the superclass matching. 270 | *

0 means ex matches exactly. Returns -1 if there's no match. 271 | * Otherwise, returns depth. Lowest depth wins. 272 | */ 273 | protected int getDepth(String exceptionMapping, Exception ex) { 274 | return getDepth(exceptionMapping, ex.getClass(), 0); 275 | } 276 | 277 | private int getDepth(String exceptionMapping, Class exceptionClass, int depth) { 278 | if (exceptionClass.getName().contains(exceptionMapping)) { 279 | // Found it! 280 | return depth; 281 | } 282 | // If we've gone as far as we can go and haven't found it... 283 | if (exceptionClass.equals(Throwable.class)) { 284 | return -1; 285 | } 286 | return getDepth(exceptionMapping, exceptionClass.getSuperclass(), depth + 1); 287 | } 288 | 289 | 290 | protected Map toRestErrors(Map smap) { 291 | if (CollectionUtils.isEmpty(smap)) { 292 | return Collections.emptyMap(); 293 | } 294 | 295 | Map map = new LinkedHashMap(smap.size()); 296 | 297 | for (Map.Entry entry : smap.entrySet()) { 298 | String key = entry.getKey(); 299 | String value = entry.getValue(); 300 | RestError template = toRestError(value); 301 | map.put(key, template); 302 | } 303 | 304 | return map; 305 | } 306 | 307 | protected RestError toRestError(String exceptionConfig) { 308 | String[] values = StringUtils.commaDelimitedListToStringArray(exceptionConfig); 309 | if (values == null || values.length == 0) { 310 | throw new IllegalStateException("Invalid config mapping. Exception names must map to a string configuration."); 311 | } 312 | 313 | RestError.Builder builder = new RestError.Builder(); 314 | 315 | boolean statusSet = false; 316 | boolean codeSet = false; 317 | boolean msgSet = false; 318 | boolean devMsgSet = false; 319 | boolean moreInfoSet = false; 320 | 321 | for (String value : values) { 322 | 323 | String trimmedVal = StringUtils.trimWhitespace(value); 324 | 325 | //check to see if the value is an explicitly named key/value pair: 326 | String[] pair = StringUtils.split(trimmedVal, "="); 327 | if (pair != null) { 328 | //explicit attribute set: 329 | String pairKey = StringUtils.trimWhitespace(pair[0]); 330 | if (!StringUtils.hasText(pairKey)) { 331 | pairKey = null; 332 | } 333 | String pairValue = StringUtils.trimWhitespace(pair[1]); 334 | if (!StringUtils.hasText(pairValue)) { 335 | pairValue = null; 336 | } 337 | if ("status".equalsIgnoreCase(pairKey)) { 338 | int statusCode = getRequiredInt(pairKey, pairValue); 339 | builder.setStatus(statusCode); 340 | statusSet = true; 341 | } else if ("code".equalsIgnoreCase(pairKey)) { 342 | int code = getRequiredInt(pairKey, pairValue); 343 | builder.setCode(code); 344 | codeSet = true; 345 | } else if ("msg".equalsIgnoreCase(pairKey)) { 346 | builder.setMessage(pairValue); 347 | msgSet = true; 348 | } else if ("devMsg".equalsIgnoreCase(pairKey)) { 349 | builder.setDeveloperMessage(pairValue); 350 | devMsgSet = true; 351 | } else if ("infoUrl".equalsIgnoreCase(pairKey)) { 352 | builder.setMoreInfoUrl(pairValue); 353 | moreInfoSet = true; 354 | } 355 | } else { 356 | //not a key/value pair - use heuristics to determine what value is being set: 357 | int val; 358 | if (!statusSet) { 359 | val = getInt("status", trimmedVal); 360 | if (val > 0) { 361 | builder.setStatus(val); 362 | statusSet = true; 363 | continue; 364 | } 365 | } 366 | if (!codeSet) { 367 | val = getInt("code", trimmedVal); 368 | if (val > 0) { 369 | builder.setCode(val); 370 | codeSet = true; 371 | continue; 372 | } 373 | } 374 | if (!moreInfoSet && trimmedVal.toLowerCase().startsWith("http")) { 375 | builder.setMoreInfoUrl(trimmedVal); 376 | moreInfoSet = true; 377 | continue; 378 | } 379 | if (!msgSet) { 380 | builder.setMessage(trimmedVal); 381 | msgSet = true; 382 | continue; 383 | } 384 | if (!devMsgSet) { 385 | builder.setDeveloperMessage(trimmedVal); 386 | devMsgSet = true; 387 | continue; 388 | } 389 | if (!moreInfoSet) { 390 | builder.setMoreInfoUrl(trimmedVal); 391 | moreInfoSet = true; 392 | //noinspection UnnecessaryContinue 393 | continue; 394 | } 395 | } 396 | } 397 | 398 | return builder.build(); 399 | } 400 | 401 | private static int getRequiredInt(String key, String value) { 402 | try { 403 | int anInt = Integer.valueOf(value); 404 | return Math.max(-1, anInt); 405 | } catch (NumberFormatException e) { 406 | String msg = "Configuration element '" + key + "' requires an integer value. The value " + 407 | "specified: " + value; 408 | throw new IllegalArgumentException(msg, e); 409 | } 410 | } 411 | 412 | private static int getInt(String key, String value) { 413 | try { 414 | return getRequiredInt(key, value); 415 | } catch (IllegalArgumentException iae) { 416 | return 0; 417 | } 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /main/src/main/java/com/stormpath/spring/web/servlet/handler/MapRestErrorConverter.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 Stormpath, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.stormpath.spring.web.servlet.handler; 17 | 18 | import org.springframework.http.HttpStatus; 19 | 20 | import java.util.LinkedHashMap; 21 | import java.util.Map; 22 | 23 | /** 24 | * Simple {@code RestErrorConverter} implementation that creates a new Map instance based on the specified RestError 25 | * instance. Some {@link org.springframework.http.converter.HttpMessageConverter HttpMessageConverter}s (like a JSON 26 | * converter) can easily automatically convert Maps to response bodies. The map is populated with the following 27 | * default name/value pairs: 28 | * 29 | * 30 | * 31 | * 32 | * 33 | * 34 | * 35 | * 36 | * 37 | * 38 | * 39 | * 40 | * 41 | * 42 | * 43 | * 44 | * 45 | * 46 | * 47 | * 48 | * 49 | * 50 | * 51 | * 52 | * 53 | * 54 | * 55 | * 56 | * 57 | * 58 | * 59 | * 60 | *
Key (a String)Value (an Object)Notes
statusrestError.{@link RestError#getStatus() getStatus()}.{@link org.springframework.http.HttpStatus#value() value()}
coderestError.{@link RestError#getCode() getCode()}Only set if {@code code > 0}
messagerestError.{@link RestError#getMessage() getMessage()}Only set if {@code message != null}
developerMessagerestError.{@link RestError#getDeveloperMessage() getDeveloperMessage()}Only set if {@code developerMessage != null}
moreInforestError.{@link RestError#getMoreInfoUrl() getMoreInfoUrl()}Only set if {@code moreInfoUrl != null}
61 | *

62 | * The map key names are customizable via setter methods (setStatusKey, setMessageKey, etc). 63 | * 64 | * @author Les Hazlewood 65 | */ 66 | public class MapRestErrorConverter implements RestErrorConverter { 67 | 68 | private static final String DEFAULT_STATUS_KEY = "status"; 69 | private static final String DEFAULT_CODE_KEY = "code"; 70 | private static final String DEFAULT_MESSAGE_KEY = "message"; 71 | private static final String DEFAULT_DEVELOPER_MESSAGE_KEY = "developerMessage"; 72 | private static final String DEFAULT_MORE_INFO_URL_KEY = "moreInfoUrl"; 73 | 74 | private String statusKey = DEFAULT_STATUS_KEY; 75 | private String codeKey = DEFAULT_CODE_KEY; 76 | private String messageKey = DEFAULT_MESSAGE_KEY; 77 | private String developerMessageKey = DEFAULT_DEVELOPER_MESSAGE_KEY; 78 | private String moreInfoUrlKey = DEFAULT_MORE_INFO_URL_KEY; 79 | 80 | @Override 81 | public Map convert(RestError re) { 82 | Map m = createMap(); 83 | HttpStatus status = re.getStatus(); 84 | m.put(getStatusKey(), status.value()); 85 | 86 | int code = re.getCode(); 87 | if (code > 0) { 88 | m.put(getCodeKey(), code); 89 | } 90 | 91 | String message = re.getMessage(); 92 | if (message != null) { 93 | m.put(getMessageKey(), message); 94 | } 95 | 96 | String devMsg = re.getDeveloperMessage(); 97 | if (devMsg != null) { 98 | m.put(getDeveloperMessageKey(), devMsg); 99 | } 100 | 101 | String moreInfoUrl = re.getMoreInfoUrl(); 102 | if (moreInfoUrl != null) { 103 | m.put(getMoreInfoUrlKey(), moreInfoUrl); 104 | } 105 | 106 | return m; 107 | } 108 | 109 | protected Map createMap() { 110 | return new LinkedHashMap(); 111 | } 112 | 113 | public String getStatusKey() { 114 | return statusKey; 115 | } 116 | 117 | public void setStatusKey(String statusKey) { 118 | this.statusKey = statusKey; 119 | } 120 | 121 | public String getCodeKey() { 122 | return codeKey; 123 | } 124 | 125 | public void setCodeKey(String codeKey) { 126 | this.codeKey = codeKey; 127 | } 128 | 129 | public String getMessageKey() { 130 | return messageKey; 131 | } 132 | 133 | public void setMessageKey(String messageKey) { 134 | this.messageKey = messageKey; 135 | } 136 | 137 | public String getDeveloperMessageKey() { 138 | return developerMessageKey; 139 | } 140 | 141 | public void setDeveloperMessageKey(String developerMessageKey) { 142 | this.developerMessageKey = developerMessageKey; 143 | } 144 | 145 | public String getMoreInfoUrlKey() { 146 | return moreInfoUrlKey; 147 | } 148 | 149 | public void setMoreInfoUrlKey(String moreInfoUrlKey) { 150 | this.moreInfoUrlKey = moreInfoUrlKey; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /main/src/main/java/com/stormpath/spring/web/servlet/handler/RestError.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 Stormpath, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.stormpath.spring.web.servlet.handler; 17 | 18 | import org.springframework.http.HttpStatus; 19 | import org.springframework.util.ObjectUtils; 20 | 21 | /** 22 | * @author Les Hazlewood 23 | */ 24 | public class RestError { 25 | 26 | private final HttpStatus status; 27 | private final int code; 28 | private final String message; 29 | private final String developerMessage; 30 | private final String moreInfoUrl; 31 | private final Throwable throwable; 32 | 33 | public RestError(HttpStatus status, int code, String message, String developerMessage, String moreInfoUrl, Throwable throwable) { 34 | if (status == null) { 35 | throw new NullPointerException("HttpStatus argument cannot be null."); 36 | } 37 | this.status = status; 38 | this.code = code; 39 | this.message = message; 40 | this.developerMessage = developerMessage; 41 | this.moreInfoUrl = moreInfoUrl; 42 | this.throwable = throwable; 43 | } 44 | 45 | public HttpStatus getStatus() { 46 | return status; 47 | } 48 | 49 | public int getCode() { 50 | return code; 51 | } 52 | 53 | public String getMessage() { 54 | return message; 55 | } 56 | 57 | public String getDeveloperMessage() { 58 | return developerMessage; 59 | } 60 | 61 | public String getMoreInfoUrl() { 62 | return moreInfoUrl; 63 | } 64 | 65 | public Throwable getThrowable() { 66 | return throwable; 67 | } 68 | 69 | @Override 70 | public boolean equals(Object o) { 71 | if (this == o) { 72 | return true; 73 | } 74 | if (o instanceof RestError) { 75 | RestError re = (RestError) o; 76 | return ObjectUtils.nullSafeEquals(getStatus(), re.getStatus()) && 77 | getCode() == re.getCode() && 78 | ObjectUtils.nullSafeEquals(getMessage(), re.getMessage()) && 79 | ObjectUtils.nullSafeEquals(getDeveloperMessage(), re.getDeveloperMessage()) && 80 | ObjectUtils.nullSafeEquals(getMoreInfoUrl(), re.getMoreInfoUrl()) && 81 | ObjectUtils.nullSafeEquals(getThrowable(), re.getThrowable()); 82 | } 83 | 84 | return false; 85 | } 86 | 87 | @Override 88 | public int hashCode() { 89 | //noinspection ThrowableResultOfMethodCallIgnored 90 | return ObjectUtils.nullSafeHashCode(new Object[]{ 91 | getStatus(), getCode(), getMessage(), getDeveloperMessage(), getMoreInfoUrl(), getThrowable() 92 | }); 93 | } 94 | 95 | public String toString() { 96 | //noinspection StringBufferReplaceableByString 97 | return new StringBuilder().append(getStatus().value()) 98 | .append(" (").append(getStatus().getReasonPhrase()).append(" )") 99 | .toString(); 100 | } 101 | 102 | public static class Builder { 103 | 104 | private HttpStatus status; 105 | private int code; 106 | private String message; 107 | private String developerMessage; 108 | private String moreInfoUrl; 109 | private Throwable throwable; 110 | 111 | public Builder() { 112 | } 113 | 114 | public Builder setStatus(int statusCode) { 115 | this.status = HttpStatus.valueOf(statusCode); 116 | return this; 117 | } 118 | 119 | public Builder setStatus(HttpStatus status) { 120 | this.status = status; 121 | return this; 122 | } 123 | 124 | public Builder setCode(int code) { 125 | this.code = code; 126 | return this; 127 | } 128 | 129 | public Builder setMessage(String message) { 130 | this.message = message; 131 | return this; 132 | } 133 | 134 | public Builder setDeveloperMessage(String developerMessage) { 135 | this.developerMessage = developerMessage; 136 | return this; 137 | } 138 | 139 | public Builder setMoreInfoUrl(String moreInfoUrl) { 140 | this.moreInfoUrl = moreInfoUrl; 141 | return this; 142 | } 143 | 144 | public Builder setThrowable(Throwable throwable) { 145 | this.throwable = throwable; 146 | return this; 147 | } 148 | 149 | public RestError build() { 150 | if (this.status == null) { 151 | this.status = HttpStatus.INTERNAL_SERVER_ERROR; 152 | } 153 | return new RestError(this.status, this.code, this.message, this.developerMessage, this.moreInfoUrl, this.throwable); 154 | } 155 | } 156 | } -------------------------------------------------------------------------------- /main/src/main/java/com/stormpath/spring/web/servlet/handler/RestErrorConverter.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 Stormpath, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.stormpath.spring.web.servlet.handler; 17 | 18 | import org.springframework.core.convert.converter.Converter; 19 | 20 | /** 21 | * A {@code RestErrorConverter} is an intermediate 'bridge' component in the response rendering pipeline: it converts 22 | * a {@link RestError} object into another object that is potentially better suited for HTTP response rendering by an 23 | * {@link org.springframework.http.converter.HttpMessageConverter HttpMessageConverter}. 24 | *

25 | * For example, a {@code RestErrorConverter} implementation might produce an intermediate Map of name/value pairs. 26 | * This resulting map might then be given to an {@code HttpMessageConverter} to write the response body: 27 | *

28 |  *     Object result = mapRestErrorConverter.convert(aRestError);
29 |  *     assert result instanceof Map;
30 |  *     ...
31 |  *     httpMessageConverter.write(result, ...);
32 |  * 
33 | *

34 | * This allows spring configurers to use or write simpler RestError conversion logic and let the more complex registered 35 | * {@code HttpMessageConverter}s operate on the converted result instead of needing to implement the more 36 | * complex {@code HttpMessageConverter} interface directly. 37 | * 38 | * @param The type of object produced by the converter. 39 | * 40 | * @see org.springframework.http.converter.HttpMessageConverter 41 | * @see Converter 42 | * 43 | * @author Les Hazlewood 44 | */ 45 | public interface RestErrorConverter extends Converter { 46 | 47 | /** 48 | * Converts the RestError instance into an object that will then be used by an 49 | * {@link org.springframework.http.converter.HttpMessageConverter HttpMessageConverter} to render the response body. 50 | * 51 | * @param re the {@code RestError} instance to convert to another object instance 'understood' by other registered 52 | * {@code HttpMessageConverter} instances. 53 | * @return an object suited for HTTP response rendering by an {@code HttpMessageConverter} 54 | */ 55 | T convert(RestError re); 56 | } 57 | -------------------------------------------------------------------------------- /main/src/main/java/com/stormpath/spring/web/servlet/handler/RestErrorResolver.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 Stormpath, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.stormpath.spring.web.servlet.handler; 17 | 18 | import org.springframework.web.context.request.ServletWebRequest; 19 | 20 | /** 21 | * A {@code RestErrorResolver} resolves an exception and produces a {@link RestError} instance that can be used 22 | * to render a Rest error representation to the response body. 23 | * 24 | * @author Les Hazlewood 25 | */ 26 | public interface RestErrorResolver { 27 | 28 | /** 29 | * Returns a {@code RestError} instance to render as the response body based on the given exception. 30 | * 31 | * @param request current {@link ServletWebRequest} that can be used to obtain the source request/response pair. 32 | * @param handler the executed handler, or null if none chosen at the time of the exception 33 | * (for example, if multipart resolution failed) 34 | * @param ex the exception that was thrown during handler execution 35 | * @return a resolved {@code RestError} instance to render as the response body or null for default 36 | * processing 37 | */ 38 | RestError resolveError(ServletWebRequest request, Object handler, Exception ex); 39 | } 40 | -------------------------------------------------------------------------------- /main/src/main/java/com/stormpath/spring/web/servlet/handler/RestExceptionHandler.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 Stormpath, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.stormpath.spring.web.servlet.handler; 17 | 18 | import org.slf4j.Logger; 19 | import org.slf4j.LoggerFactory; 20 | import org.springframework.beans.factory.InitializingBean; 21 | import org.springframework.http.HttpInputMessage; 22 | import org.springframework.http.HttpOutputMessage; 23 | import org.springframework.http.MediaType; 24 | import org.springframework.http.converter.HttpMessageConverter; 25 | import org.springframework.http.server.ServletServerHttpRequest; 26 | import org.springframework.http.server.ServletServerHttpResponse; 27 | import org.springframework.util.CollectionUtils; 28 | import org.springframework.web.context.request.ServletWebRequest; 29 | import org.springframework.web.servlet.ModelAndView; 30 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; 31 | import org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver; 32 | import org.springframework.web.util.WebUtils; 33 | 34 | import javax.servlet.ServletException; 35 | import javax.servlet.http.HttpServletRequest; 36 | import javax.servlet.http.HttpServletResponse; 37 | import java.io.IOException; 38 | import java.util.ArrayList; 39 | import java.util.Collections; 40 | import java.util.List; 41 | 42 | /** 43 | * Renders a response with a RESTful Error representation based on the error format discussed in 44 | * 45 | * Spring MVC Rest Exception Handling Best Practices. 46 | *

47 | * At a high-level, this implementation functions as follows: 48 | * 49 | *

    50 | *
  1. Upon encountering an Exception, the configured {@link RestErrorResolver} is consulted to resolve the 51 | * exception into a {@link RestError} instance.
  2. 52 | *
  3. The HTTP Response's Status Code will be set to the {@code RestError}'s 53 | * {@link com.stormpath.spring.web.servlet.handler.RestError#getStatus() status} value.
  4. 54 | *
  5. The {@code RestError} instance is presented to a configured {@link RestErrorConverter} to allow transforming 55 | * the {@code RestError} instance into an object potentially more suitable for rendering as the HTTP response body.
  6. 56 | *
  7. The 57 | * {@link #setMessageConverters(org.springframework.http.converter.HttpMessageConverter[]) HttpMessageConverters} 58 | * are consulted (in iteration order) with this object result for rendering. The first 59 | * {@code HttpMessageConverter} instance that {@link HttpMessageConverter#canWrite(Class, org.springframework.http.MediaType) canWrite} 60 | * the object based on the request's supported {@code MediaType}s will be used to render this result object as 61 | * the HTTP response body.
  8. 62 | *
  9. If no {@code HttpMessageConverter}s {@code canWrite} the result object, nothing is done, and this handler 63 | * returns {@code null} to indicate other ExceptionResolvers potentially further in the resolution chain should 64 | * handle the exception instead.
  10. 65 | *
66 | * 67 | *

Defaults

68 | * This implementation has the following property defaults: 69 | * 70 | * 71 | * 72 | * 73 | * 74 | * 75 | * 76 | * 77 | * 78 | * 79 | * 80 | * 81 | * 82 | * 83 | * 87 | * 88 | * 89 | * 90 | * 91 | * 93 | * 94 | *
PropertyInstanceNotes
errorResolver{@link DefaultRestErrorResolver DefaultRestErrorResolver}Converts Exceptions to {@link RestError} instances. Should be suitable for most needs.
errorConverter{@link MapRestErrorConverter}Converts {@link RestError} instances to {@code java.util.Map} instances to be used as the response body. 84 | * Maps can then be trivially rendered as JSON by a (configured) 85 | * {@link HttpMessageConverter HttpMessageConverter}. If you want the raw {@code RestError} instance to 86 | * be presented to the {@code HttpMessageConverter} instead, set this property to {@code null}.
messageConvertersmultiple instancesDefault collection are those automatically enabled by Spring as 92 | * defined here (specifically item #5)
95 | * 96 | *

JSON Rendering

97 | * This implementation comes pre-configured with Spring's typical default 98 | * {@link HttpMessageConverter} instances; JSON will be enabled automatically if Jackson is in the classpath. If you 99 | * want to match the JSON representation shown in the article above (recommended) but do not want to use Jackson 100 | * (or the Spring's default Jackson config), you will need to 101 | * {@link #setMessageConverters(org.springframework.http.converter.HttpMessageConverter[]) configure} a different 102 | * JSON-capable {@link HttpMessageConverter}. 103 | * 104 | * @see DefaultRestErrorResolver 105 | * @see MapRestErrorConverter 106 | * @see HttpMessageConverter 107 | * @see org.springframework.http.converter.json.MappingJacksonHttpMessageConverter MappingJacksonHttpMessageConverter 108 | * 109 | * @author Les Hazlewood 110 | */ 111 | public class RestExceptionHandler extends AbstractHandlerExceptionResolver implements InitializingBean { 112 | 113 | private static final Logger log = LoggerFactory.getLogger(RestExceptionHandler.class); 114 | 115 | private HttpMessageConverter[] messageConverters = null; 116 | 117 | private List> allMessageConverters = null; 118 | 119 | private RestErrorResolver errorResolver; 120 | 121 | private RestErrorConverter errorConverter; 122 | 123 | public RestExceptionHandler() { 124 | this.errorResolver = new DefaultRestErrorResolver(); 125 | this.errorConverter = new MapRestErrorConverter(); 126 | } 127 | 128 | public void setMessageConverters(HttpMessageConverter[] messageConverters) { 129 | this.messageConverters = messageConverters; 130 | } 131 | 132 | public void setErrorResolver(RestErrorResolver errorResolver) { 133 | this.errorResolver = errorResolver; 134 | } 135 | 136 | public RestErrorResolver getErrorResolver() { 137 | return this.errorResolver; 138 | } 139 | 140 | public RestErrorConverter getErrorConverter() { 141 | return errorConverter; 142 | } 143 | 144 | public void setErrorConverter(RestErrorConverter errorConverter) { 145 | this.errorConverter = errorConverter; 146 | } 147 | 148 | @Override 149 | public void afterPropertiesSet() throws Exception { 150 | ensureMessageConverters(); 151 | } 152 | 153 | @SuppressWarnings("unchecked") 154 | private void ensureMessageConverters() { 155 | 156 | List> converters = new ArrayList>(); 157 | 158 | //user configured values take precedence: 159 | if (this.messageConverters != null && this.messageConverters.length > 0) { 160 | converters.addAll(CollectionUtils.arrayToList(this.messageConverters)); 161 | } 162 | 163 | //defaults next: 164 | new HttpMessageConverterHelper().addDefaults(converters); 165 | 166 | this.allMessageConverters = converters; 167 | } 168 | 169 | //leverage Spring's existing default setup behavior: 170 | private static final class HttpMessageConverterHelper extends WebMvcConfigurationSupport { 171 | public void addDefaults(List> converters) { 172 | addDefaultHttpMessageConverters(converters); 173 | } 174 | } 175 | 176 | /** 177 | * Actually resolve the given exception that got thrown during on handler execution, returning a ModelAndView that 178 | * represents a specific error page if appropriate. 179 | *

180 | * May be overridden in subclasses, in order to apply specific 181 | * exception checks. Note that this template method will be invoked after checking whether this resolved applies 182 | * ("mappedHandlers" etc), so an implementation may simply proceed with its actual exception handling. 183 | * 184 | * @param request current HTTP request 185 | * @param response current HTTP response 186 | * @param handler the executed handler, or null if none chosen at the time of the exception (for example, 187 | * if multipart resolution failed) 188 | * @param ex the exception that got thrown during handler execution 189 | * @return a corresponding ModelAndView to forward to, or null for default processing 190 | */ 191 | @Override 192 | protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { 193 | 194 | ServletWebRequest webRequest = new ServletWebRequest(request, response); 195 | 196 | RestErrorResolver resolver = getErrorResolver(); 197 | 198 | RestError error = resolver.resolveError(webRequest, handler, ex); 199 | if (error == null) { 200 | return null; 201 | } 202 | 203 | ModelAndView mav = null; 204 | 205 | try { 206 | mav = getModelAndView(webRequest, handler, error); 207 | } catch (Exception invocationEx) { 208 | log.error("Acquiring ModelAndView for Exception [" + ex + "] resulted in an exception.", invocationEx); 209 | } 210 | 211 | return mav; 212 | } 213 | 214 | protected ModelAndView getModelAndView(ServletWebRequest webRequest, Object handler, RestError error) throws Exception { 215 | 216 | applyStatusIfPossible(webRequest, error); 217 | 218 | Object body = error; //default the error instance in case they don't configure an error converter 219 | 220 | RestErrorConverter converter = getErrorConverter(); 221 | if (converter != null) { 222 | body = converter.convert(error); 223 | } 224 | 225 | return handleResponseBody(body, webRequest); 226 | } 227 | 228 | private void applyStatusIfPossible(ServletWebRequest webRequest, RestError error) { 229 | if (!WebUtils.isIncludeRequest(webRequest.getRequest())) { 230 | webRequest.getResponse().setStatus(error.getStatus().value()); 231 | } 232 | //TODO support response.sendError ? 233 | } 234 | 235 | @SuppressWarnings("unchecked") 236 | private ModelAndView handleResponseBody(Object body, ServletWebRequest webRequest) throws ServletException, IOException { 237 | 238 | HttpInputMessage inputMessage = new ServletServerHttpRequest(webRequest.getRequest()); 239 | 240 | List acceptedMediaTypes = inputMessage.getHeaders().getAccept(); 241 | if (acceptedMediaTypes.isEmpty()) { 242 | acceptedMediaTypes = Collections.singletonList(MediaType.ALL); 243 | } 244 | 245 | MediaType.sortByQualityValue(acceptedMediaTypes); 246 | 247 | HttpOutputMessage outputMessage = new ServletServerHttpResponse(webRequest.getResponse()); 248 | 249 | Class bodyType = body.getClass(); 250 | 251 | List> converters = this.allMessageConverters; 252 | 253 | if (converters != null) { 254 | for (MediaType acceptedMediaType : acceptedMediaTypes) { 255 | for (HttpMessageConverter messageConverter : converters) { 256 | if (messageConverter.canWrite(bodyType, acceptedMediaType)) { 257 | messageConverter.write(body, acceptedMediaType, outputMessage); 258 | //return empty model and view to short circuit the iteration and to let 259 | //Spring know that we've rendered the view ourselves: 260 | return new ModelAndView(); 261 | } 262 | } 263 | } 264 | } 265 | 266 | if (logger.isWarnEnabled()) { 267 | logger.warn("Could not find HttpMessageConverter that supports return type [" + bodyType + 268 | "] and " + acceptedMediaTypes); 269 | } 270 | return null; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 16 | 18 | 19 | 4.0.0 20 | 21 | com.stormpath.blog 22 | spring-mvc-rest-exhandler-root 23 | 1.0.0-SNAPSHOT 24 | pom 25 | 26 | Spring MVC Rest Exception Handler 27 | 28 | 29 | 2.5 30 | 3.1.1.RELEASE 31 | 1.6.1 32 | 1.7.6 33 | 6.1.24 34 | 1.6 35 | UTF-8 36 | 37 | 38 | 39 | main 40 | example 41 | 42 | 43 | 44 | 45 | 46 | 47 | com.stormpath.blog 48 | spring-mvc-rest-exhandler 49 | ${project.version} 50 | 51 | 52 | 53 | 54 | org.codehaus.jackson 55 | jackson-mapper-asl 56 | ${jackson.version} 57 | 58 | 59 | 60 | org.slf4j 61 | slf4j-api 62 | ${slf4j.version} 63 | 64 | 65 | org.springframework 66 | spring-web 67 | ${spring.version} 68 | 69 | 70 | org.springframework 71 | spring-webmvc 72 | ${spring.version} 73 | 74 | 75 | javax.servlet 76 | servlet-api 77 | ${servlet.version} 78 | provided 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | org.apache.maven.plugins 88 | maven-compiler-plugin 89 | 2.0.2 90 | 91 | ${jdk.version} 92 | ${jdk.version} 93 | ${project.build.sourceEncoding} 94 | 95 | 96 | 97 | 98 | 99 | 100 | --------------------------------------------------------------------------------