Jan Nielsen
Jan Nielsen

Reputation: 11829

Concise HAL+JSON and JSON endpoint implementation?

Is it possible to concisely implement a single HAL-JSON & JSON endpoints in Spring Boot 2? The goal is to have:

curl -v http://localhost:8091/books

return this application/hal+json result:

{
  "_embedded" : {
    "bookList" : [ {
      "title" : "The As",
      "author" : "ab",
      "isbn" : "A"
    }, {
      "title" : "The Bs",
      "author" : "ab",
      "isbn" : "B"
    }, {
      "title" : "The Cs",
      "author" : "cd",
      "isbn" : "C"
    } ]
  }

and for this (and/or the HTTP Accept header since this is a REST API):

curl -v http://localhost:8091/books?format=application/json

to return the plain application/json result:

[ {
  "title" : "The As",
  "author" : "ab",
  "isbn" : "A"
}, {
  "title" : "The Bs",
  "author" : "ab",
  "isbn" : "B"
}, {
  "title" : "The Cs",
  "author" : "cd",
  "isbn" : "C"
} ]

with minimal controller code. These endpoints work as expected:

@GetMapping("/asJson")
public Collection<Book> booksAsJson() {
    return _books();
}

@GetMapping("/asHalJson")
public CollectionModel<Book> booksAsHalJson() {
    return _halJson(_books());
}

@GetMapping
public ResponseEntity<?> booksWithParam(
    @RequestParam(name="format", defaultValue="application/hal+json") 
    String format) {

    return _selectedMediaType(_books(), format);
}

@GetMapping("/asDesired")
public ResponseEntity<?> booksAsDesired() {
    return _selectedMediaType(_books(), _format());
}

with the following helpers:

private String _format() {
    // TODO: something clever here...perhaps Spring's content-negotiation?
    return MediaTypes.HAL_JSON_VALUE;
}

private <T> static CollectionModel<T> _halJson(Collection<T> items) {
    return CollectionModel.of(items);
}

private <T> static ResponseEntity<?> _selectedMediaType(
    Collection<T> items, String format) {

    return ResponseEntity.ok(switch(format.toLowerCase()) {
        case MediaTypes.HAL_JSON_VALUE        -> _halJson(items);
        case MediaType.APPLICATION_JSON_VALUE -> items;
        default                               -> throw _unknownFormat(format);
    });
}

but the booksWithParam implementation is too messy to duplicate for each endpoint. Is there a way to get to, or close to, something like the booksAsDesired implementation or something similarly concise?

Upvotes: 1

Views: 1126

Answers (1)

Roman Vottner
Roman Vottner

Reputation: 12839

One way you could tell Spring that you want to support plain JSON is by adding a custom converter for such media types. This can be done by overwriting the extendMessageConverters method of WebMvcConfigurer and adding your custom converters there like in the sample below:

import ...PlainJsonHttpMessageConverter;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.web.config.EnableSpringDataWebSuport;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servelt.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

import javax.annotation.Nonnull;

@Configuration
@EnableSpringeDataWebSupport
public class WebMvcConfiguration implements WebMvcConfigurer {

  @Override
  public void extendMessageConverters(@Nonnull final List<HttpMessageConverter<?>> converters) {
    converters.add(new PlainJsonHttpMessageConverter());
  }
}

The message converter itself is also no rocket-science as can be seen by the PlainJsonHttpMessageConverter sample below:

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsr310.JavaTimeModule;

import org.springframework.hateoas.RepresentationModel;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter;
import org.springframework.stereotype.Component;

import javax.annotation.Nonnull;

@Component
public class PlainJsonHttpMessageConverter extends AbstractJackson2HttpMessageConverter {
  public PlainJsonHttpMessageConverter() {
    super(new ObjectMapper(), MediaType.APPLICATION_JSON);
    // add support for date and time format conversion to ISO 8601 and others
    this.defaultObjectMapper.registerModule(new JavaTimeModule());
    // return JSON payload in pretty format
    this.defaultObjectMapper.enable(SerializationFeature.INDENT_OUTPUT);
  }

  @Override
  protected boolean supports(@Nonnull final Class<?> clazz) {
    return RepresentationModel.class.isAssignableFrom(clazz);
  }
}

This should enable plain JSON support besides HAL-JSON without you having to do any further branching or custom media-type specific conversion within your domain logic or service code.

I.e. let's take a simple task as example case. Within a TaskController you might have a code like this

@GetMapping(path = "/{taskId:.+}", produces = {
  MediaTypes.HAL_JSON_VALUE,
  MediaType.APPLICATION_JSON_VALUE,
  MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE
})
public ResponseEntity<?> task(@PathVariable("taskId") String taskId,
                              @RequestParam(required = false) Map<String, String> queryParams,
                              HttpServletRequest request) {
  if (queryParams == null) {
    queryParams = new HashMap<>();
  }
  Pageable pageable = RequestUtils.getPageableForInput(queryParams);
  final String caseId = queryParams.get("caseId");
  ...

  final Query query = buildSearchCriteria(taskId, caseId, ...);
  query.with(pageable);
  List<Task> matches = mongoTemplate.find(query, Task.class);

  if (!matches.isEmpty()) {
    final Task task = matches.get(0);
    
    return ResponseEntity.ok()
        .eTag(Long.toString(task.getVersion())
        .body(TASK_ASSEMBLER.toModel(task));
  } else {
    if (request.getHeader("Accept").contains(MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE)) {
      return ResponseEntity.status(HttpStatus.NOT_FOUND)
          .contentType(MediaTypes.HTTP_PROBLEM_DETAILS_JSON)
          .body(generateNotFoundProblem(request, taskId));
    } else {
      final String msg = "No task with ID " + taskId + " found";
      throw new ResponseStatusException(HttpStatus.NOT_FOUND, msg);
    }
  }
}

which simply retrieves an arbitrary task via its unique identifier and returns the representation for it according to the one specified in the Accept HTTP header. The TASK_ASSEMBLER here is just a custom Spring HATEOAS RepresentationModelAssembler<Task, TaskResource> class that converts task objects to task resources by adding links for certain related things.

This can now be easily tested via Spring MVC tests such as

@Test
public void halJson() throws Exception {
  given(mongoTemplate.find(any(Query.class), eq(Task.class)))
    .willReturn(setupSingleTaskList());
  final ResultActions result = mockMvc.perform(
      get("/api/tasks/taskId")
          .accept(MediaTypes.HAL_JSON_VALUE)
  );
  result.andExpect(status().isOk())
      .andExpect(content().contentType(MediaTypes.HAL_JSON_VALUE));
  // see raw payload received by commenting out below line
  // System.err.println(result.andReturn().getResponse().getContentAsString());
  verifyHalJson(result);
}

@Test
public void plainJson() throws Exception {
  given(mongoTemplate.find(any(Query.class), eq(Task.class)))
    .willReturn(setupSingleTaskList());
  final ResultActions result = mockMvc.perform(
      get("/api/tasks/taskId")
          .accept(MediaType.APPLICATION_JSON_VALUE)
  );
  result.andExpect(status().isOk())
      .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE));
  // see raw payload received by commenting out below line
  // System.err.println(result.andReturn().getResponse().getContentAsString());
  verifyPlainJson(result);
}

...

private void verifyHalJson(final ResultActions action) throws Exception {
  action.andExpect(jsonPath("taskId", is("taskId")))
      .andExpect(jsonPath("caseId", is("caseId")))
      ...
      .andExpect(jsonPath("_links.self.href", is(BASE_URI + "/tasks/taskId")))
      .andExpect(jsonPath("_links.up.href", is(BASE_URI + "/tasks")));
}

rivate void verifyPlainJson(final ResultActions action) throws Exception {
  action.andExpect(jsonPath("taskId", is("taskId")))
      .andExpect(jsonPath("caseId", is("caseId")))
      ...
      .andExpect(jsonPath("links[0].rel", is("self")))
      .andExpect(jsonPath("links[0].href", is(BASE_URI + "/tasks/taskId")))
      .andExpect(jsonPath("links[1].rel", is("up")))
      .andExpect(jsonPath("links[1].href", is(BASE_URI + "/tasks")));
}

Note how links are presented here differently depending on which media type you've selected.

Upvotes: 1

Related Questions