This project provides JSON:API media type support for Spring HATEOAS.

© 2021 The original authors.

Copies of this document may be made for your own use and for distribution to others, provided that you do not charge any fee for such copies and further provided that each copy contains this Copyright Notice, whether distributed in print or electronically.

Fundamentals

For further understanding of this document, please be aware of both

The following documentation assumes that the reader knows above documents.

JSON:API

JSON:API is a widely adapted hypermedia format. You can find a list of implementations and tools here. Answers to a few questions, e.g. related to HAL, you can find here.

Anytime a client supplies an Accept header with application/vnd.api+json, you can expect something like this:

{
  "jsonapi": {
    "version": "1.0"
  },
  "data": [
    {
      "id": "1",
      "type": "movies",
      "attributes": {
        "title": "The Shawshank Redemption",
        "year": 1994,
        "rating": 9.3
      },
      "relationships": {
        "directors": {
          "data": [
            {
              "id": "2",
              "type": "directors"
            }
          ],
          "links": {
            "self": "http://localhost:8080/api/movies/1/relationships/directors",
            "related": "http://localhost:8080/api/movies/1/directors"
          }
        }
      },
      "links": {
        "self": "http://localhost:8080/api/movies/1"
      }
    }
  ],
  "included": [
    {
      "id": "2",
      "type": "directors",
      "attributes": {
        "name": "Frank Darabont"
      }
    }
  ],
  "links": {
    "self": "http://localhost:8080/api/movies?page[number]=0&page[size]=1",
    "next": "http://localhost:8080/api/movies?page[number]=1&page[size]=1",
    "last": "http://localhost:8080/api/movies?page[number]=249&page[size]=1"
  },
  "meta": {
    "page": {
      "number": 0,
      "size": 1,
      "totalPages": 250,
      "totalElements": 250
    }
  }
}

Integration in your Backends

To enable the JSON:API media type you just need to add this module as a dependency to your project.

Maven:

<dependency>
    <groupId>com.toedter</groupId>
    <artifactId>spring-hateoas-jsonapi</artifactId>
    <version>0.16.1</version>
</dependency>

Gradle:

implementation 'com.toedter:spring-hateoas-jsonapi:0.16.1'

The latest published snapshot version is 0.17.0-SNAPSHOT. If you want to try it out, please make sure to add oss.sonatype.org/content/repositories/snapshots/ as repository to your Maven or Gradle configuration.

Representation Models

All Spring HATEOAS representation models are rendered as JSON:API. Consider a simple Movie Class as base for a Spring HATEOAS entity model:

@Data
@NoArgsConstructor
@AllArgsConstructor
@With
public class Movie {
    private String id;
    private String title;
}

An EntityModel.of(new Movie("1", "Star Wars")) is then rendered as

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    }
  }
}

In JSON:API, the id field must be of type String. But in your model you can use any Class and toString() is used for conversion. So, if the id attribute of Movie would be of type long, the rendered JSON:API would be the same. The JSON:API type is automatically generated of the pluralized, lower case, simple class name. This is best practice, since then most likely the type matches the URL (end) of the corresponding REST collection resource.

You can configure if you want to use non-pluralized class names, see Configuration

Annotations

The goal of this implementation is to automate the mapping from/to JSON:API as convenient as possible.

There are four new annotations provided by this project:

  • @JsonApiId to mark a JSON:API id

  • @JsonApiType to mark a field or method to provide a JSON:API type

  • @JsonApiTypeForClass to mark class to provide a JSON:API type

    • The JSON:API type is a required value of this annotation

  • @JsonApiRelationships to mark a JSON:API relationship

The use of these annotations is optional. For the mapping of the id, the following rules apply in order:

  • the annotation @JsonApiId is used on a field

  • the annotation @JsonApiId is used on a method

  • the annotation @Id (javax.persistence.Id) is used on a field

  • the annotation @Id (javax.persistence.Id) is used on a method

  • the entity (base for representation models) provides an attribute id

For the mapping of the type, the following rules apply in order:

  • the annotation @JsonApiTypeForClass is used on a class

  • the annotation @JsonApiType is used on a field

  • the annotation @JsonApiType is used on a method

  • if no annotation is present, the pluralized, lower case, simple class name of the entity will be used

You can configure if you want to use non-pluralized class names, see Configuration

As an example, consider the class

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Movie2 {
    @Id
    private String myId;
    private String title;
    @JsonApiType
    private String type;
}

Then, EntityModel.of(new Movie2("1", "Star Wars", "my-movies"))) will be rendered as

{
  "data": {
    "id": "1",
    "type": "my-movies",
    "attributes": {
      "title": "Star Wars"
    }
  }
}

JSON:API Builder

If you want to use JSON:API relationships or included data, you can use the JsonApiModelBuilder. The following example shows how to create a JSON::API representation model using the JsonApiModelBuilder

import static com.toedter.spring.hateoas.jsonapi.JsonApiModelBuilder.jsonApiModel;
Movie movie = new Movie("1", "Star Wars");
final RepresentationModel<?> jsonApiModel = jsonApiModel().model(movie).build();

Consider that you want to express the relationships of movies to their directors. A simple Director class could look like:

@Data
@NoArgsConstructor
@AllArgsConstructor
@With
public class Director {
    private String id;
    private String name;
}

You can build a relationship from a movie to a director like

Movie movie = new Movie("1", "Star Wars");
Director director = new Director("1", "George Lucas");
final RepresentationModel<?> jsonApiModel =
        jsonApiModel()
                .model(movie)
                .relationship("directors", director)
                .build();

The representation model will be rendered as

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    },
    "relationships": {
      "directors": {
        "data": {
          "id": "1",
          "type": "directors"
        }
      }
    }
  }
}

If you want the directors relationship always being rendered as an array, even if it is empty or contains only a single data element, you can build it like:

final RepresentationModel<?> jsonApiModel =
        jsonApiModel()
                .model(EntityModel.of(movie))
                .relationshipWithDataArray("directors")
                .relationship("directors", director)
                .build();

The representation model will be rendered as

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    },
    "relationships": {
      "directors": {
        "data": [
          {
            "id": "3",
            "type": "directors"
          }
        ]
      }
    }
  }
}

You can also pass a Java Collection as data for a relationship. A collection will always be rendered as JSON array, even when it is empty or contains a single element. So,

final RepresentationModel<?> jsonApiModel =
        jsonApiModel()
                .model(EntityModel.of(movie))
                .relationship("directors", Collections.singletonList(director))
                .build();

would be rendered exactly like the previous example.

The builder also provides methods for adding links and meta to a relationship. Check out the Javadoc API documentation for more details.

If you want to include the related resources in the JSON:API output, you can build included director resources like:

Movie movie = new Movie("1", "The Matrix");
Movie relatedMovie = new Movie("2", "The Matrix 2");
Director director1 = new Director("1", "Lana Wachowski");
Director director2 = new Director("2", "Lilly Wachowski");

final RepresentationModel<?> jsonApiModel =
        jsonApiModel()
                .model(movie)
                .relationship("directors", director1)
                .relationship("directors", director2)
                .relationship("relatedMovies", relatedMovie)
                .included(director1)
                .included(director2)
                .build();

The representation model will be rendered as

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "The Matrix"
    },
    "relationships": {
      "relatedMovies": {
        "data": {
          "id": "2",
          "type": "movies"
        }
      },
      "directors": {
        "data": [
          {
            "id": "1",
            "type": "directors"
          },
          {
            "id": "2",
            "type": "directors"
          }
        ]
      }
    }
  },
  "included": [
    {
      "id": "1",
      "type": "directors",
      "attributes": {
        "name": "Lana Wachowski"
      }
    },
    {
      "id": "2",
      "type": "directors",
      "attributes": {
        "name": "Lilly Wachowski"
      }
    }
  ]
}

The following example shows the creation of a more complex JSON:API specific representation model with a paged model as base. The builder supports adding both pagination metadata and pagination links.

Movie movie = new Movie("1", "The Matrix");
Movie relatedMovie = new Movie("2", "The Matrix 2");
Director director1 = new Director("1", "Lana Wachowski");
Director director2 = new Director("2", "Lilly Wachowski");

final RepresentationModel<?> jsonApiModel1 =
        jsonApiModel()
                .model(movie)
                .relationship("directors", director1)
                .relationship("directors", director2)
                .relationship("relatedMovies", EntityModel.of(relatedMovie))
                .build();

Movie movie2 = new Movie("3", "Star Wars");
Director director3 = new Director("3", "George Lucas");

final RepresentationModel<?> jsonApiModel2 =
        jsonApiModel()
                .model(movie2)
                .relationship("directors", director3)
                .build();

List<RepresentationModel<?>> movies = new ArrayList<>();
movies.add(jsonApiModel1);
movies.add(jsonApiModel2);

PagedModel.PageMetadata pageMetadata = new PagedModel.PageMetadata(10, 1, 100, 10);
Link selfLink = Link.of("http://localhost/movies").withSelfRel();
final PagedModel<RepresentationModel<?>> pagedModel = PagedModel.of(movies, pageMetadata, selfLink);

RepresentationModel<?> pagedJasonApiModel =
        jsonApiModel()
                .model(pagedModel)
                .included(director1)
                .included(director2)
                .included(director3)
                .pageMeta()
                .pageLinks("http://localhost/movies")
                .build();

This model will be rendered as

{
  "data": [
    {
      "id": "1",
      "type": "movies",
      "attributes": {
        "title": "The Matrix"
      },
      "relationships": {
        "relatedMovies": {
          "data": {
            "id": "2",
            "type": "movies"
          }
        },
        "directors": {
          "data": [
            {
              "id": "1",
              "type": "directors"
            },
            {
              "id": "2",
              "type": "directors"
            }
          ]
        }
      }
    },
    {
      "id": "3",
      "type": "movies",
      "attributes": {
        "title": "Star Wars"
      },
      "relationships": {
        "directors": {
          "data": {
            "id": "3",
            "type": "directors"
          }
        }
      }
    }
  ],
  "included": [
    {
      "id": "1",
      "type": "directors",
      "attributes": {
        "name": "Lana Wachowski"
      }
    },
    {
      "id": "2",
      "type": "directors",
      "attributes": {
        "name": "Lilly Wachowski"
      }
    },
    {
      "id": "3",
      "type": "directors",
      "attributes": {
        "name": "George Lucas"
      }
    }
  ],
  "links": {
    "self": "http://localhost/movies",
    "first": "http://localhost/movies?page[number]=0&page[size]=10",
    "prev": "http://localhost/movies?page[number]=0&page[size]=10",
    "next": "http://localhost/movies?page[number]=2&page[size]=10",
    "last": "http://localhost/movies?page[number]=9&page[size]=10"
  },
  "meta": {
    "page": {
      "number": 1,
      "size": 10,
      "totalPages": 10,
      "totalElements": 100
    }
  }
}

Inclusion of related Resources

There is no direct support for automatically including related resources, but a REST controller could provide an optional request parameter like

@RequestParam(value = "included", required = false) String[] included)

Then, within the controller implementation, this parameter could be interpreted, and the builder could be used for the inclusion, like

if (included != null && included.length == 1 && included[0].equals("directors")) {
    HashMap<Long, Director> directors = new HashMap<>();
    for (Movie movie : pagedResult.getContent()) {
        jsonApiModelBuilder.included(movie.getDirectors());
    }
}

Duplicated included directors will be eliminated automatically.

Nesting of JsonApiModels

When using the model builder, JsonApiModel instances can be used as model and included resources. Here is an example that also illustrates the different levels of meta.

Director director = new Director("3", "George Lucas");
final RepresentationModel<?> directorModel =
        jsonApiModel()
                .model(EntityModel.of(director))
                .meta("director-meta", "director-meta-value")
                .build();

Map<String, Object> relationshipMeta = new HashMap<>();
relationshipMeta.put("relationship-meta", "relationship-meta-value");

Map<String, Object> directorRelationshipMeta = new HashMap<>();
directorRelationshipMeta.put("director-relationship-meta", "director-relationship-meta-value");

Movie movie = new Movie("1", "Star Wars");
final RepresentationModel<?> movieModel =
        jsonApiModel()
                .model(movie)
                .meta("movie-meta", "movie-meta-value")
                .relationship("directors", director, directorRelationshipMeta)
                .relationship("directors", relationshipMeta)
                .build();

final RepresentationModel<?> jsonApiModel =
        jsonApiModel()
                .model(movieModel)
                .meta("top-level-meta", "top-level-meta-value")
                .included(directorModel)
                .build();

This model will be rendered as

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    },
    "relationships": {
      "directors": {
        "data": {
          "id": "3",
          "type": "directors",
          "meta": {
            "director-relationship-meta": "director-relationship-meta-value"
          }
        },
        "meta": {
          "relationship-meta": "relationship-meta-value"
        }
      }
    },
    "meta": {
      "movie-meta": "movie-meta-value"
    }
  },
  "included": [
    {
      "id": "3",
      "type": "directors",
      "attributes": {
        "name": "George Lucas"
      },
      "meta": {
        "director-meta": "director-meta-value"
      }
    }
  ],
  "meta": {
    "top-level-meta": "top-level-meta-value"
  }
}

Sparse Fieldsets

Sparse fieldsets are supported for attributes within data and included. You can add sparse fieldsets by using the JsonApiBuilder. The following example illustrates the build, assuming a director would have the attributes name and born, and a movie would have the attributes title and rating.

MovieWithRating movie = new MovieWithRating("1", "Star Wars", 8.6);
DirectorWithMovies director = new DirectorWithMovies("3", "George Lucas", 1944);
director.setMovies(Collections.singletonList(movie));

final RepresentationModel<?> jsonApiModel =
        jsonApiModel()
                .model(EntityModel.of(director))
                .fields("directors", "name")
                .fields("movies", "title")
                .relationship("movies", movie)
                .included(movie)
                .build();

So, only the name attribute of a director, and the title attribute of a movie would be serialized:

{
  "data": {
    "id": "3",
    "type": "directors",
    "attributes": {
      "name": "George Lucas"
    },
    "relationships": {
      "movies": {
        "data": {
          "id": "1",
          "type": "movies"
        }
      }
    }
  },
  "included": [
    {
      "id": "1",
      "type": "movies",
      "attributes": {
        "title": "Star Wars"
      }
    }
  ]
}

In a REST controller, a method with HTTP-mapping could provide an optional request attribute for each sparse fieldset that should be applied to a specific JSON:API type, like:

@RequestParam(value = "fields[movies]", required = false) String[] fieldsMovies,
@RequestParam(value = "fields[directors]", required = false) String[] fieldsDirectors)

In the following controller code, you could check the existence of these request parameters, like:

if (fieldsDirectors != null) {
    builder = builder.fields("directors", fieldsDirectors);
}

When adding sparse fieldsets to the builder, they will NOT automatically exclude added relationships. Relationships have to be added conditionally, like the inclusions, for example:

if (fieldsDirectors == null || Arrays.asList(fieldsDirectors).contains("movies")) {
    builder = builder.relationship("movies", director.getMovies());
}

Creating Resources with HTTP POST

To create new REST resources using HTTP POST, you can provide JSON:API formatted JSON as input. For example, a POST with the body:

{
  "data": {
    "type": "movies",
    "attributes": {
      "title": "Batman Begins"
    }
  }
}

will be deserialized to an EntityModel<Movie> automatically. You can also create REST resources that contain JSON:API relationships. You just have to annotate the underlying domain model class, with JsonApiRelationships(<relationship name>)

For example, a POST with the body:

{
  "data": {
    "type": "movies",
    "attributes": {
      "title": "New Movie"
    },
    "relationships": {
      "directors": {
        "data": [
          {
            "id": "1",
            "type": "directors"
          },
          {
            "id": "2",
            "type": "directors"
          }
        ]
      }
    }
  }
}

will be deserialized to an EntityModel<Movie> with a filled list of directors, where ONLY the id attribute of each director is set. The REST controller then has to interpret those relationships and bind the real director objects to the movie.

Here is an example of a class using the annotation:

@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@With
public class MovieWithDirectors extends Movie {
    @JsonApiType
    String myType = "movies";

    @JsonIgnore
    @JsonApiRelationships("directors")
    List<Director> directors;
}
If you use the annotation JsonApiRelationships on an attribute of a Java class, the content will NOT be serialized automatically to JSON:API relationships. This is on purpose, please use the JsonApiModelBuilder to decide, which relationships and included objects you want to return.

Deserialization of JSON:API types

If entities contain an explicit @JsonApiType field annotation, those fields are also filled during deserialization. This is also true for relationships, if the relationship entity contains an explicit @JsonApiType annotation.

Consider the following classes:

@Data
@NoArgsConstructor
@AllArgsConstructor
@With
public class DirectorWithType {
    private String id;
    private String name;

    @JsonApiType
    private  String directorType;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@With
public class MovieWithTypedDirectors {
    private String id;
    private String title;

    @JsonApiType
    String myType;

    @JsonIgnore
    @JsonApiRelationships("directors")
    List<DirectorWithType> directors;
}

Then the following JSON

{
  "data": {
    "type": "movies",
    "attributes": {
      "title": "New Movie"
    },
    "relationships": {
      "directors": {
        "data": [
          {
            "id": "1",
            "type": "director-type-1"
          },
          {
            "id": "2",
            "type": "director-type-2"
          }
        ]
      }
    }
  }
}

will be deserialized to a MovieWithTypedDirectors where myType is "movies" and 2 (empty) DirectorWithType objects. The first DirectorWithType object with id = '1' and directorType = "director-type-1", the second DirectorWithType object with id = '2' and directorType = "director-type-2",

Currently, only List and Set are supported collection classes.

Polymorphic Deserialization

You can configure polymorphic deserialization on a 'per class' base, using some Jackson annotations. The following example illustrates this:

Imagine a controller method like

@PostMapping("/movies")
public ResponseEntity<?> newMovie(@RequestBody EntityModel<Movie> movie) { ...

and a subclass of Movie that contains a rating, like

@NoArgsConstructor
@Data
@JsonApiTypeForClass("movies")
public class MovieWithRating extends Movie {
    private double rating;
}

You could now annotate the Movie class with:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
@JsonSubTypes({
    @JsonSubTypes.Type(value = MovieWithRating.class, name = "movieWithRating")
})

Then a HTTP POST to /movies with body

{
  "data": {
    "type": "movies",
    "attributes": {
      "@type": "movieWithRating",
      "title": "Batman Begins",
      "rating": 8.2
    }
  }
}

would be deserialized to an object of class MovieWithRating, even though the controller method accepts the superclass Movie.

@-members will be introduced in JSON:API version 1.1. The above example will produce JSON that is not compliant with JSON:API 1.0. For JSON:API 1.0 compliance you could configure Jackson like @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "typeName") instead. From the JSON:API 1.1 spec: …​because @-Members must be ignored when interpreting that definition, an @-Member that occurs in an attributes object is not an attribute.

Important: The above mechanism is also used for serialization, so you could set the JSON:API type attribute (within data) to a more generic type, but still serialize the @type attribute to indicate the specialized type. So the JSON of the serialized Java object (of class MovieWithRating) would then look like:

{
  "data": {
    "id": "3",
    "type": "movies",
    "attributes": {
      "@type": "movieWithRating",
      "title": "Batman Begins",
      "rating": 8.2
    }
  },
  "links": {
    "self": "http://localhost/movies/3"
  }
}

Polymorphic Deserialization of Relationships

It is also possible to use the @JsonSubTypes annotation for polymorphic relationships. Here is an example:

@NoArgsConstructor
public class PolymorphicRelationEntity {
    @JsonApiId
    private String id;

    @JsonApiType
    private String type = null;

    @JsonApiRelationships("superEntities")
    @JsonIgnore
    @Getter
    private List<SuperEntity<?>> relation = null;
}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(value = SuperEChild.class, name = "superEChild"),
        @JsonSubTypes.Type(value = SuperEChild2.class, name = "superEChild2")
})
public interface SuperEntity<T> {
    T getT();
}
@NoArgsConstructor
public class SuperEChild<T extends Collection<?>> implements SuperEntity<T> {
    @JsonApiId
    private String id;

    @JsonApiType
    private String type = null;

    @Override
    public T getT() {
        return null;
    }
}
@NoArgsConstructor
public class SuperEChild2 implements SuperEntity<String> {
    @JsonApiId
    private String id;

    @JsonApiType
    private String type = null;

    private String extraAttribute = "";

    @Override
    public String getT() {
        return null;
    }
}

Then a JSON like

{
  "data": {
    "id": "poly123",
    "type": "polymorphicRelationEntity",
    "relationships": {
      "superEntities": {
        "data": [
          {
            "id": "456",
            "type": "superEChild"
          },
          {
            "id": "789",
            "type": "superEChild2"
          }
        ]
      }
    }
  }
}

would be deserialized to an PolymorphicRelationEntity with 2 relationships, the first one of Class SuperEChild, the second one of class SuperEChild2.

Right now there is the restriction, that the type attribute of both SuperEChild and SuperEChild2 has to be type. E.g. _type would not work.

Configuration

There are several options how to change the output of the JSON:API rendering.

For a specific JSON:API configuration, you can create a Spring bean of type JsonApiConfiguration. Currently, you can configure

  • if the JSON:API version should be rendered automatically, the default is false.

  • if JSON:API types should be rendered as pluralized or non pluralized class names.

    • The default is pluralized.

  • if JSON:API types should be rendered as lower cased or original class names.

    • The default is lower cased.

  • if page information of a PagedModel should be rendered automatically as JSON:API meta object.

    • The default is true.

  • if a specific Java class should be rendered with a specific JSON:API type. This is useful when representation model classes should get the JSON:API type of the domain model or when derived classes should get the JSON:API type of the super class. See example below.

  • A lambda expression to add additional configuration to the Jackson ObjectMapper used for serialization.

Since the JSON:API recommendation contains square brackets in the request parameter names, make sure you provide the following configuration in your Spring application.properties when using Tomcat: server.tomcat.relaxed-query-chars= [,]

Here is an example how you would implement a JSON:API configuration:

@Bean
JsonApiConfiguration jsonApiConfiguration() {
    return new JsonApiConfiguration()
            .withJsonApiVersionRendered(true)
            .withPluralizedTypeRendered(false)
            .withLowerCasedTypeRendered(false)
            .withTypeForClass(MovieRepresentationModelWithoutJsonApiType.class, "my-movies")
            .withObjectMapperCustomizer(
                    objectMapper -> objectMapper.configure(
                            SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true));
}

Error Handling

To create JSON:API compliant error messages, you can use JsonApiErrors and JsonApiError

Here is an example how to produce an error response:

return ResponseEntity.badRequest().body(
        JsonApiErrors.create().withError(
                JsonApiError.create()
                        .withAboutLink("http://movie-db.com/problem")
                        .withTitle("Movie-based problem")
                        .withStatus(HttpStatus.BAD_REQUEST.toString())
                        .withDetail("This is a test case")));

The result would be rendered as:

{
  "errors": [
    {
      "links": {
        "about": "http://movie-db.com/problem"
      },
      "status": "400 BAD_REQUEST",
      "title": "Movie-based problem",
      "detail": "This is a test case"
    }
  ]
}