I have following controller in my spring boot application:
#RequestMapping(method = RequestMethod.POST)
public ResponseEntity<ResponseDto<MyClass> process(#RequestBody RequestDto<MyClass> request){
return null;
}
MyClass has a field, let's say 'myField' and I want different NamingStrategyconfiguration for request and response for this field (this is because I don't want to create a new class just for one field). I have configured ObjectMapper instance as below:
#Bean
public ObjectMapper objectMapper(){
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setPropertyNamingStrategy(namingStrategy);
return objectMapper;
}
This will be used both for Request and Response (i.e. deserialization and serialization), is there any way in spring boot by which I can instruct the controller to use different ObjectMapper instances?
You can solve it with content negotiation. Firstly, define your custom HttpMessageConverter. In following example I have defined a custom converter that is applied when the request Content-Type header is set to application/test+json:
#Bean
public HttpMessageConverters customConverters() {
final AbstractJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(new ObjectMapper());
converter.setSupportedMediaTypes(Collections.singletonList(MediaType.valueOf("application/test+json")));
return new HttpMessageConverters(true, Collections.singletonList(converter));
}
For simplicity of this example I've used newly created ObjectMapper - in your case you will have to pass here previously configured object.
Next thing is to tell your action to accept only appliction/test+json requests (keep in mind, that from now on it requires to Content-Type:application/test+json header to present in every request to this endpoint):
#RequestMapping(method = RequestMethod.POST, consumes = "application/test+json")
public ResponseEntity<ResponseDto<MyClass> process(#RequestBody RequestDto<MyClass> request){
return null;
}
Last thing is to make sure that when you call this endpoint, Content-Type:application/test+json header is set. Of course you can use any other name for desired content type, presented name is just an example.
You can use a deserialization modifier in your ObjectMapper to override the set of enabled features at object deserialization time via a module. This one should do the trick:
public class FeatureModifyingBeanDeserializerModifier extends BeanDeserializerModifier {
private Collection<Class<?>> modifiedClasses;
public FeatureModifyingBeanDeserializerModifier(Collection<Class<?>> modifiedClasses) {
this.modifiedClasses = Collections.unmodifiableSet(new HashSet<Class<?>>(modifiedClasses));
}
#Override
public JsonDeserializer<?> modifyDeserializer(
DeserializationConfig config, BeanDescription beanDesc, final JsonDeserializer<?> deserializer) {
JsonDeserializer<?> result = deserializer;
Class<?> beanClass = beanDesc.getBeanClass();
if (modifiedClasses.contains(beanClass)) {
result = new FeatureModifyingStdDeserializer(deserializer, beanClass);
}
return result;
}
private static class FeatureModifyingStdDeserializer extends StdDeserializer<Object> {
private JsonDeserializer<?> deserializer;
private FeatureModifyingStdDeserializer(
JsonDeserializer<?> deserializer, Class<?> beanClass) {
super(beanClass);
this.deserializer = deserializer;
}
#Override
public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
p.enable(JsonParser.Feature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER);
return deserializer.deserialize(p, ctxt);
}
}
}
You have to register it with the ObjectMapper as a module like this:
ObjectMapper objectMapper = new ObjectMapper();
SimpleModule module = new SimpleModule();
module.setDeserializerModifier(new FeatureModifyingBeanDeserializerModifer(Arrays.asList(Journey.class)));
objectMapper.registerModule(module);
For serialization, you can add an #JsonSerialize annotation to the Journey class and serialize it in whatever way you want. If you need to write an unescaped string you can use writeRaw from JsonGenerator.
One dirty hack: you may write custom serializer and deserializer for MyClass, there you explicitly use two separate object mapper one for serialization (for response) and second for deserialization (for request).
But it's better to find a way to explicitly customize spring object mapper.
Related
For a Spring MVC project (not Spring Boot) I'm configuring the JSON converter to customise JSON responses of all REST endpoints i.e. removing null fields and setting a date format. After introducing SpringDoc to the project I've had to add a StringHttpMessageConverter to prevent the generated OpenAPI JSON from being returned as a string.
Without the StringHttpMessageConverter the OpenAPI JSON looks like this:
"{\"openapi\":\"3.0.1\",\"info\":{\"title\":\"OpenAPI definition\",\"version\":\"v0\"},\"servers\":[{\"url\":\"http://localhost:8080\",\"description\":\"Generated server url\"}],\"paths\":{\"/get\":{\"get\":{\"tags\":[\"controller\"],\"operationId\":\"getSomeMap\",\"responses\":{\"200\":{\"description\":\"default response\",\"content\":{\"*/*\":{\"schema\":{\"$ref\":\"#/components/schemas/ImmutableMultimapStringString\"}}}}}}}},\"components\":{\"schemas\":{\"ImmutableMultimapStringString\":{\"type\":\"object\",\"properties\":{\"empty\":{\"type\":\"boolean\"}}}}}}"
With the StringHttpMessageConverter it looks like this, which is the desired result:
{"openapi":"3.0.1","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"paths":{"/get":{"get":{"tags":["controller"],"operationId":"getSomeMap","responses":{"200":{"description":"default response","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ImmutableMultimapStringString"}}}}}}}},"components":{"schemas":{"ImmutableMultimapStringString":{"type":"object","properties":{"empty":{"type":"boolean"}}}}}}
This does however cause problems with several endpoints that return a string as their response. They should return a valid JSON string: "response-string" but instead they return the string as plain text: response-string, omitting the double quotes, making it invalid JSON.
How can I keep the current configuration intact so the SpringDoc OpenAPI JSON is returned correctly while also having endpoints that have a string response return a valid JSON string?
Configuration used:
#Configuration
#EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
#Override
public void addInterceptors(InterceptorRegistry registry) {
WebContentInterceptor webContentInterceptor = new WebContentInterceptor();
webContentInterceptor.setCacheSeconds(0);
webContentInterceptor.setUseExpiresHeader(true);
webContentInterceptor.setUseCacheControlHeader(true);
webContentInterceptor.setUseCacheControlNoStore(true);
registry.addInterceptor(webContentInterceptor);
}
#Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
Map<String, MediaType> mediaTypes = new HashMap<>();
mediaTypes.put("json", MediaType.APPLICATION_JSON);
mediaTypes.put("xml", MediaType.APPLICATION_XML);
configurer.favorPathExtension(false);
configurer.favorParameter(true);
configurer.mediaTypes(mediaTypes);
configurer.defaultContentType(MediaType.APPLICATION_JSON_UTF8);
}
#Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}
#Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// Note that the order matters here! If the StringHttpMessageConverter is add after the jsonConverter
// the documentation JSON is returned as a giant string instead of a (valid) JSON object
converters.add(new StringHttpMessageConverter());
converters.add(jsonConverter());
}
#Bean
public MappingJackson2HttpMessageConverter jsonConverter() {
List<MediaType> supportedMediaTypes = new ArrayList<>();
supportedMediaTypes.add(MediaType.APPLICATION_JSON);
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.serializationInclusion(JsonInclude.Include.NON_NULL);
builder.timeZone(TimeZone.getTimeZone(timeZone));
MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter(
builder.build()
);
jsonConverter.setSupportedMediaTypes(supportedMediaTypes);
return jsonConverter;
}
#Bean
public Jaxb2Marshaller jaxb2Marshaller() {
Jaxb2Marshaller jaxb2Marshaller = new Jaxb2Marshaller();
jaxb2Marshaller.setClassesToBeBound(KioskProfiel.class, KioskProfielRegel.class, TitlesetTO.class, TitlesetTitel.class);
return jaxb2Marshaller;
}
#Bean
public MarshallingHttpMessageConverter marshallingConverter() {
List<MediaType> supportedMediaTypes = new ArrayList<>();
supportedMediaTypes.add(MediaType.APPLICATION_XML);
MarshallingHttpMessageConverter marshallingConverter = new MarshallingHttpMessageConverter(jaxb2Marshaller());
marshallingConverter.setSupportedMediaTypes(supportedMediaTypes);
return marshallingConverter;
}
}
Edit
I've tried overriding the OpenApiResource setting the produces value of the endpoint to TEXT_PLAIN_VALUE and too application/json but the problem still persists. Attempting to change the return type from String to TextNode isn't allowed so that doesn't seem to be an option.
Alternatively I've tried to resolve the problem by registering a Filter to correct the malformed response but that to doesn't work.
Maybe I'm still missing something but I'm out of options. With my current project configuration I can't get SpringDoc to return valid OpenAPI JSON when using a custom MappingJackson2HttpMessageConverter. For now I'll stick to Swagger 2.0 and will look into an alternative library to move to OpenAPI 3.0.
A working solution has finally been found! It consists of two parts. The first is configuring the converters. In short we register the default converts after which the default JSON converter, MappingJackson2HttpMessageConverter is removed and our custom JSON converter is added as the first converter to the list of converters. It is important that the custom JSON converter is in the list of converters before the StringHttpMessageConverter else endpoints that return JSON that have a String as their Java return type return the string without double quotes making it invalid JSON.
#Configuration
#EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
#Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.removeIf(converter -> converter instanceof MappingJackson2HttpMessageConverter);
converters.add(0, jsonConverter());
}
#Bean
public MappingJackson2HttpMessageConverter jsonConverter() {
List<MediaType> supportedMediaTypes = new ArrayList<>();
supportedMediaTypes.add(MediaType.APPLICATION_JSON);
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.serializationInclusion(JsonInclude.Include.NON_NULL);
builder.timeZone(TimeZone.getTimeZone(timeZone));
MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter(
builder.build()
);
jsonConverter.setSupportedMediaTypes(supportedMediaTypes);
return jsonConverter;
}
}
Secondly, this causes the OpenAPI JSON to be one big (escaped) string, as mentioned in the question. To resolve this problem we override the openapiJson method (and endpoint) from the OpenApiWebMvcResource class, which is used by default to return the OpenAPI JSON, to produce text/plain instead of application/json. This way the documentation JSON isn't returned as an (escaped) string anymore.
#RestController
public class OpenApiResource extends OpenApiWebMvcResource {
#Override
#Operation(hidden = true)
#GetMapping(value = Constants.API_DOCS_URL, produces = MediaType.TEXT_PLAIN_VALUE)
public String openapiJson(
HttpServletRequest request,
#Value(Constants.API_DOCS_URL) String apiDocsUrl
)
throws JsonProcessingException {
calculateServerUrl(request, apiDocsUrl);
OpenAPI openAPI = this.getOpenApi();
return Json.mapper().writeValueAsString(openAPI);
}
}
Note that for brevity only the relevant methods are listed in both example classes above.
Another quick workaround is to configure swagger-ui to use the YAML version of the OpenAPI documentation (instead of JSON) by simply adding this property to your application.yaml:
springdoc.swagger-ui.url: /v3/api-docs.yaml
In my application I configured Jackson to use SerializationFeature.WRAP_ROOT_VALUE and DeserializationFeature.UNWRAP_ROOT_VALUE globally.
#Configuration
public class AppConfig {
public Jackson2ObjectMapperBuilder jacksonBuilder() {
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.featuresToEnable(SerializationFeature.WRAP_ROOT_VALUE, DeserializationFeature.UNWRAP_ROOT_VALUE);
return builder;
}
}
This configuration works fine but now I am in a situation where in deserialization case I get a JSON Response without rootname. So I have got a Service Class which builds a RestTemplate using RestTemplateBuilder and POST some Data to a REST-Webservice.
#Service
public class ApiServiceImpl
implements ApiService<RegisterResponse> {
private RestTemplate restTemplate;
public ApiServiceImpl(RestTemplateBuilder restTemplateBuilder) {
restTemplate = restTemplateBuilder
.errorHandler(new RestTemplateResponseErrorHandler()).build();
}
#Override
public ResponseEntity<RegisterResponse> callAPI(String requestAsJson,
String username, String password) {
ResponseEntity<RegisterResponse> result = null;
HttpHeaders headers = getHeaders(username, password);
result = restTemplate.exchange(uri, HttpMethod.POST,
new HttpEntity<String>(requestAsJson, headers),
RegisterResponse.class);
return result;
}
}
The Response looks like the following:
{
"redirect-url": "https://any-url.com/?with=params"
}
And I want to deserialize this to the following POJO directly. (Like in restTemplate.exchange configured)
public class RegisterResponse {
#JsonProperty("redirect-url")
private String redirectUrl;
//getter/setter
}
It's clear to get this exception because of the UNWRAP_ROOT_VALUE Feature:
com.fasterxml.jackson.databind.exc.MismatchedInputException: Root name 'redirect-url' does not match expected ('RegisterResponse') for type [simple type, class xxx.xxx.xxxservice.xxx.model.response.entity.RegisterResponse]
at [Source: (String)"{
"redirect-url": "https://any-url.com/?with=params"
}"; line: 2, column: 5]
at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59)
at com.fasterxml.jackson.databind.DeserializationContext.reportInputMismatch(DeserializationContext.java:1356)
at com.fasterxml.jackson.databind.ObjectMapper._unwrapAndDeserialize(ObjectMapper.java:4087)
How can I configure Jackson to dont use DeserializationFeature.UNWRAP_ROOT_VALUE in this particular case?
Like JB Nizet commented, its possibly by setting a new Instance of MappingJackson2HttpMessageConverter and ObjectMapper for Jakson to the list of MessageConverters.
restTemplate.getMessageConverters().add(getCustomConverter());
private MappingJackson2HttpMessageConverter getCustomConverter() {
ObjectMapper mapper = new ObjectMapper();
mapper.enable(DeserializationFeature.UNWRAP_ROOT_VALUE);
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
MappingJackson2HttpMessageConverter customConverter =
new MappingJackson2HttpMessageConverter(mapper);
if (!restTemplate.getMessageConverters()
.removeIf(MappingJackson2HttpMessageConverter.class::isInstance)) {
new RuntimeException("Custom MappingJackson2HttpMessageConverter not found");
}
return customConverter;
}
I would like to be able to convert a generic Object in a request to a custom object that I have built using Spring Boot and Jackson.
For example, say I have the following controller method:
#RequestMapping(value = "/user/action", method = RequestMethod.POST)
public String processAction(#RequestBody Request theRequest){
return "Success";
}
Now the Request object looks like this:
public class Request {
public Request(){
}
private String action;
private Object actionRequest;
//getters/setters
The actual actionRequest can be any Object (e.g. Boolean, String, or a custom built one).
So say I have a custom built ActionA class:
public class ActionA {
public ActionA(){
}
private int actionAInfo;
//getters/setters
}
If I invoke my controller with a POST and a payload of
{"action": "startEstimate", "actionRequest":true}
when it reaches the method, the 'actionRequest' is already converted to a boolean.
However, if I provide the payload
{"action": "startEstimate", "actionRequest": {"actionAInfo": 5}}
the 'actionRequest' is just converted to a HashMap with key of 'actionAInfo' and value of '5'.
How can I configure Spring Boot/Jackson to create the ActionA object?
One workaround I have seen is to instead of having Object in the Request, use ObjectNode. Then do something similar to
ObjectMapper om = new ObjectMapper();
ActionA actionA = om.convertValue(theRequest.getActionRequest(), ActionA.class);
However, this does not expand as I add more actions because I would need to know the type before I attempt to build it.
I have also attempted a solution presented here
Custom JSON Deserialization with Jackson
and it does not seem to be working. I have created a new ActionADeserializer
public class ActionADeserializer extends JsonDeserializer<ActionA> {
#Override
public ActionA deserialize(JsonParser jp, DeserializationContext ctxt) throws
IOException,
JsonProcessingException {
return jp.readValueAs(ActionA.class);
}
}
and altered ActionA class to have
#JsonDeserialize(using = ActionADeserializer.class)
public class ActionA
When I submit the payload
{"action": "startEstimate", "actionRequest": {"actionAInfo": 5}}
it still creates the HashMap instead of the ActionA object. I set a breakpoint in the deserializer and do not even see it being invoked.
In a Spring application I have added a custom JSON serializer which is applied to a field with thte tag:
#JsonSerialize(using=MySerializer.class)
And the MySerializer serializer (whcihc extends from JsonSerializer) has a method which looks like:
#Override
public void serialize(String value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException, JsonProcessingException
{
//some logic
}
It works and execute the logic when the application generates json in the methods annotated with #ResponseBody but it also executes the same logic when the application uses a webservice with JSON. I'd like to set up different configurations for the RestTemplate serialization and for #ResponseBody, or at least, being able to differenciate in the serializate method whether we are in a #ResponseBody case or in a RestTemplate one.
Any idea on how to do this?
Thanks.
You should use Mix-in Annotations feature. For example, your POJO classes could look like that:
class Root {
private Data data;
// getters/setters
}
class Data {
private String name;
// getters/setters
}
Now, you have to create MixIn interface:
interface RootMixIn {
#JsonSerialize(using = DataSerializer.class)
Data getData();
}
Imagine, that custom serializer looks like this:
class DataSerializer extends JsonSerializer<Data> {
#Override
public void serialize(Data data, JsonGenerator generator, SerializerProvider provider) throws IOException, JsonProcessingException {
generator.writeStartObject();
generator.writeFieldName("name_property");
generator.writeString(data.getName() + " XXX");
generator.writeEndObject();
}
}
Finally, you have to create two ObjectMapper's. Simple usage:
Data data = new Data();
data.setName("Tom");
Root root = new Root();
root.setData(data);
ObjectMapper mapperWithMixIn = new ObjectMapper();
mapperWithMixIn.addMixInAnnotations(Root.class, RootMixIn.class);
ObjectMapper mapperDefault = new ObjectMapper();
System.out.println("With MIX-IN");
System.out.println(mapperWithMixIn.writeValueAsString(root));
System.out.println("Default");
System.out.println(mapperDefault.writeValueAsString(root));
Above script prints:
With MIX-IN
{"data":{"name_property":"Tom XXX"}}
Default
{"data":{"name":"Tom"}}
As you can see, ObjectMapper with MixIn you can use in request handlers, and default ObjectMapper you can use in RestTemplate.
Update 1
Spring creates default ObjectMapper bean and uses it to serialize and deserialize request's data. You have to find and override this default bean. How to do it? Please, see below links:
Spring, Jackson and Customization (e.g. CustomDeserializer).
How to customise the Jackson JSON mapper in Spring Web MVC.
This overridden bean should look like mapperWithMixIn in my above example.
Secondly, you have to create new ObjectMapper bean and inject this bean to all RestTemplate-s and use this bean in these classes as a serializer/deserializer.
I would like to deserialize JSON (with Jackson 1.9.11 and RestTemplate 1.0.1), in which one field may have more type meanings, for example:
{"responseId":123,"response":"error"}
or
{"responseId":123,"response":{"foo":"bar", ... }}
Either one or other case works correctly with one setter of specific type (String od custom Response class), but when I put into my entity bean overriden setter to be able to handle both cases, exception is thrown:
Caused by: org.springframework.web.client.RestClientException: Could not extract response: no suitable HttpMessageConverter found for response type [xxxx.templates.ExportResponse] and content type [application/json;charset=utf-8]
I was thinking about three solutions, but I did not get any of them working:
using only String setter and inside use ObjectMapper to unmarshall that string, if it is not equal to "error", but when that JS Array comes, it's not string so no String setter is used :(.
use polymorphic type handling (#JsonTypeInfo annotation) with own JsonDeserializer extension - I'm still trying to understand this and implement.
create list of HttpMessageConverter and put inside all message converters, I can use. But I thing this step is unnecessary, because only MappingJacksonHttpMessageConverter is used, am I right?
EDIT: how it works now
Setter in entity bean:
#JsonDeserialize(using = ResponseDeserializer.class)
public void setResponse(Object responseObject) {
if(responseObject instanceof Response)
response = (Response) responseObject;
}
Deserialize method in ResponseDeserializer:
public Response deserialize(JsonParser parser, DeserializationContext context) throws IOException, JsonProcessingException {
Response response = new Response();
if(JsonToken.START_OBJECT.equals(parser.getCurrentToken())) {
ObjectMapper mapper = new ObjectMapper();
response = mapper.readValue(parser, Response.class);
} else
throw new JsonMappingException("Unexpected token received.");
return response;
}
The only way to achieve that is to use a custom deserializer.
Here is an example:
ObjectMapper mapper = new ObjectMapper();
SimpleModule testModule = new SimpleModule("MyModule", new Version(1, 0, 0, null));
testModule.addDeserializer(Response.class, new ResponseJsonDeserializer());
mapper.registerModule(testModule);
And here is how to write (how I would write it at least) the deserializer:
class ResponseJsonDeserializer extends JsonDeserializer<Response> {
#Override
public Responsedeserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {
Response response = new Response();
if(jp.getCurrentToken() == JsonToken.VALUE_STRING) {
response.setError(jp.getText());
} else {
// Deserialize object
}
return response;
}
}
class Response {
private String error;
private Object otherObject; // Use the real type of your object
public boolean isError() {
return error != null;
}
// Getters and setters
}