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

© 2025 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.

Project Metadata

1. Introduction

1.1. Fundamentals

JSON:API for Spring HATEOAS is based on version 3.0.1 of Spring HATEOAS. For better understanding of this document, please familiarize yourself with both:

This documentation assumes that readers are familiar with the above documents. Some parts of the Java code examples are folded; click on the icon in the bottom-right corner to expand and view the full source code.

1.2. JSON:API

JSON:API is a widely adopted hypermedia format. You can find a list of implementations and tools here. For answers to frequently asked questions (e.g., related to HAL), see here.

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

{
  "jsonapi": {
    "version": "1.1"
  },
  "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%5Bnumber%5D=0&page%5Bsize%5D=1",
    "next": "http://localhost:8080/api/movies?page%5Bnumber%5D=1&page%5Bsize%5D=1",
    "last": "http://localhost:8080/api/movies?page%5Bnumber%5D=249&page%5Bsize%5D=1"
  },
  "meta": {
    "page": {
      "size": 1,
      "totalElements": 250,
      "totalPages": 250,
      "number": 0
    }
  }
}
The characters [ and ] in the Links section are unsafe and URL encoded when added automatically by the library. So the URL decoded next link would look like: http://localhost:8080/api/movies?page[number]=1&page[size]=1.

2. Setup

To enable the JSON:API media type, simply add this module as a dependency to your project.

Gradle
implementation 'com.toedter:spring-hateoas-jsonapi:3.0.1'
Maven
<dependency>
    <groupId>com.toedter</groupId>
    <artifactId>spring-hateoas-jsonapi</artifactId>
    <version>3.0.1</version>
</dependency>

The latest published snapshot version is 3.0.2-SNAPSHOT.

2.1. Version Requirements

This library requires:

  • Spring Boot 4.x (Spring Framework 7.x)

  • Jackson 3.x (tools.jackson.*)

  • Java 17 or later

Version 3 is the first version based on Spring Boot 4 and Jackson 3. The last version supporting Spring Boot 3 is 2.2.0.

This is a breaking change if you:

  • Use deprecated APIs: All deprecated APIs have been removed (withJsonApiVersionRendered() and withObjectMapperCustomizer())

  • Use custom Jackson serializers/deserializers with the old com.fasterxml.jackson.* package

  • Directly interact with the Jackson ObjectMapper API

The Jackson 3 API uses the tools.jackson. package namespace instead of com.fasterxml.jackson..

See the Migration Guide for detailed migration instructions.

3. Migration Guide

3.1. Migrating to Version 3

Version 3 is the first release based on Spring Boot 4 and Jackson 3. The last version supporting Spring Boot 3 is 2.2.0. This section provides guidance for migrating from version 2.x.x to 3.x.x.

Version 3 removes all deprecated APIs from version 2.x.x. Code using deprecated APIs will not compile. See the Removed Deprecated APIs section for migration instructions.

3.1.1. Breaking Changes

Removed Deprecated APIs

Version 3 removes all deprecated APIs from version 2.x.x and also changes the mapper customization. You must migrate to the new APIs:

Removed API Replacement Section

withJsonApiVersionRendered(boolean)

withJsonApiObject(JsonApiObject)

JSON:API Version Rendering

withObjectMapperCustomizer(Function<ObjectMapper, ObjectMapper>)

withMapperCustomizer(UnaryOperator<JsonMapper.Builder>)

JSON Mapper Customization

Action Required: If your code uses any of the removed APIs listed above, it will not compile with version 3. You must update to use the new replacement APIs before upgrading.

Spring Boot 4 and Spring Framework 7

Version 3 requires Spring Boot 4.x and Spring Framework 7.x. Key changes include:

  • Minimum Java version: Java 17 (unchanged from 2.1.x)

  • Jakarta EE namespace: Uses jakarta. packages instead of javax.

  • RestTemplate removed: Use RestClient instead for HTTP client operations

Jackson 3 Migration

The most significant breaking change is the migration from Jackson 2 to Jackson 3.

Package Changes

Jackson 3 uses a new package namespace:

Jackson 2 (Old) Jackson 3 (New)

com.fasterxml.jackson.databind.*

tools.jackson.databind.*

com.fasterxml.jackson.core.*

tools.jackson.core.*

com.fasterxml.jackson.annotation.*

com.fasterxml.jackson.annotation.* (unchanged)

API Changes
  • ObjectMapper → JsonMapper: The JsonMapper class is now preferred over ObjectMapper

  • Custom Serializers/Deserializers: Must extend Jackson 3 base classes from tools.jackson.* packages

  • Module Registration: Modules must be compatible with Jackson 3

JSON:API Version Rendering

IMPORTANT: The deprecated withJsonApiVersionRendered() API has been removed in version 3. You must migrate to the new withJsonApiObject() API.

If you were using the jsonApiVersionRendered configuration:

Before (Version 2.x.x - Spring Boot 3)
@Bean
public JsonApiConfiguration jsonApiConfiguration() {
    return new JsonApiConfiguration()
        .withJsonApiVersionRendered(true);  (1)
}
1 REMOVED in version 3 - this will no longer compile
After (Version 3.x.x - Spring Boot 4) - REQUIRED
@Bean
public JsonApiConfiguration jsonApiConfiguration() {
    return new JsonApiConfiguration()
        .withJsonApiObject(new JsonApiObject(true, null, null, null));  (1)
}
1 Use withJsonApiObject(new JsonApiObject(true, …​)) instead of the removed withJsonApiVersionRendered(true)

The JsonApiObject provides much more flexibility and allows you to configure:

  • version: The JSON:API version (e.g., "1.1")

  • ext: List of JSON:API extensions

  • profile: List of JSON:API profiles

  • meta: Metadata for the JSON:API object

Example with all options
@Bean
public JsonApiConfiguration jsonApiConfiguration() {
    JsonApiObject jsonApiObject = new JsonApiObject(
        true,  // showVersion
        List.of(URI.create("https://jsonapi.org/ext/atomic")),  // extensions
        List.of(URI.create("http://example.com/profiles/flexible-pagination")),  // profiles
        Map.of("copyright", "Copyright 2025")  // meta
    );

    return new JsonApiConfiguration()
        .withJsonApiObject(jsonApiObject);
}
Mapper Customization

IMPORTANT: The deprecated withObjectMapperCustomizer() API has been removed in version 3. You must migrate to the new withMapperCustomizer() API.

If you were using the ObjectMapperCustomizer configuration:

Before (Version 2.x.x - Spring Boot 3)
@Bean
public JsonApiConfiguration jsonApiConfiguration() {
    return new JsonApiConfiguration()
        .withObjectMapperCustomizer(mapper -> {  (1)
            mapper.enable(SerializationFeature.INDENT_OUTPUT);
            return mapper;
        });
}
1 REMOVED in version 3 - this will no longer compile
After (Version 3.x.x - Spring Boot 4) - REQUIRED
@Bean
public JsonApiConfiguration jsonApiConfiguration() {
    return new JsonApiConfiguration()
        .withMapperCustomizer(builder ->  (1)
            builder.enable(SerializationFeature.INDENT_OUTPUT)  (2)
        );
}
1 Use withMapperCustomizer instead of the removed withObjectMapperCustomizer
2 Works with JsonMapper.Builder instead of ObjectMapper

3.1.2. Custom Serializers and Deserializers

If you have custom Jackson serializers or deserializers, you need to update them:

Before (Jackson 2)
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;

public class CustomSerializer extends StdSerializer<MyType> {
    @Override
    public void serialize(MyType value, JsonGenerator gen,
                         SerializerProvider provider) {
        // serialization logic
    }
}
After (Jackson 3)
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;  (1)
import tools.jackson.databind.ValueSerializer;  (2)

public class CustomSerializer extends ValueSerializer<MyType> {  (3)
    @Override
    public void serialize(MyType value, JsonGenerator gen,
                         SerializationContext ctxt) {  (4)
        // serialization logic (unchanged)
    }
}
1 Use SerializationContext instead of SerializerProvider
2 Use ValueSerializer instead of JsonSerializer
3 Extend ValueSerializer instead of StdSerializer
4 Parameter type changed from SerializerProvider to SerializationContext
RestTemplate → RestClient

As of Spring Framework 7.0, RestTemplate is deprecated in favor of RestClient and will be removed in a future version. For HTTP client testing, migrate from TestRestTemplate to RestTestClient:

Before (TestRestTemplate )
@Autowired
private TestRestTemplate restTemplate;

ResponseEntity<String> response = restTemplate.exchange(
    "/api/movies/1",
    HttpMethod.GET,
    entity,
    String.class
);
After (RestTestClient)
@Autowired
private RestTestClient restClient;

String response = restClient.get()
    .uri("/api/movies/1")
    .header("Accept", MediaTypes.JSON_API_VALUE)
    .exchange()
    .returnResult(String.class)
    .getResponseBody();

3.1.3. New Features in Version 3

Improved Mapper Access

Direct access to the configured JsonMapper:

JsonApiConfiguration config = new JsonApiConfiguration()
    .withMapperCustomizer(builder ->
        builder.enable(SerializationFeature.INDENT_OUTPUT)
    );

JsonMapper mapper = config.getJsonMapper();  (1)
1 Obtain a fully configured JsonMapper instance

3.1.4. Compatibility

Version 3 is a major version release due to the Spring Boot 4 and Jackson 3 migration. The main breaking changes are:

  • Removed deprecated APIs: All deprecated APIs from version 2.x.x have been removed - you must migrate to the new APIs:

    • withJsonApiVersionRendered()withJsonApiObject()

    • withObjectMapperCustomizer()withMapperCustomizer()

  • Spring Boot 4 requirement (upgraded from Spring Boot 3 in version 2.x.x)

  • Jackson 3 package namespace changes (affects custom serializers/deserializers)

  • Spring Boot 4 testing API changes (@AutoConfigureMockMvc, RestTemplate)

Migration Required: If you were using any of the deprecated APIs (withJsonApiVersionRendered() or withObjectMapperCustomizer()), your code will not compile with version 3. You must update your code to use the new APIs (withJsonApiObject() and withMapperCustomizer()) before upgrading.

If you don’t use the removed deprecated APIs or custom Jackson code, and don’t directly interact with Spring Boot testing internals, migration should be straightforward - mainly requiring updates to Spring Boot 4 compatible dependency versions.

4. Server-Side Support

4.1. Representation Models

All Spring HATEOAS representation models are rendered as JSON:API. Consider a simple Movie class as the 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. However, in your model you can use any class, and toString() is used for conversion. So, if the id attribute of Movie were of type long, the rendered JSON:API output would be the same. The JSON:API type is automatically generated from the pluralized, lowercase, simple class name. This is a best practice, as the type will most likely match the URL (endpoint) of the corresponding REST collection resource.

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

If Spring HATEOAS links contain only an href, the simple JSON:API link format is used for rendering. Here is an example of a simple self link:

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    }
  },
  "links": {
    "self": "http://localhost/movies/1"
  }
}

A complex Link object in Spring HATEOAS can have optional properties like name, type, hreflang, title, and others. In previous versions of this library, those properties were serialized as JSON:API link metadata, e.g.

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    }
  },
  "links": {
    "self": {
      "href": "https://complex-links.org",
      "meta": {
        "hreflang": "EN",
        "media": "media",
        "title": "title",
        "type": "type",
        "name": "name"
      }
    }
  }
}

JSON:API 1.1 now defines the optional link properties type, title, and hreflang, see JSON:API Link Objects. The default rendering of a complex link is now

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    }
  },
  "links": {
    "self": {
      "href": "https://complex-links.org",
      "title": "title",
      "type": "type",
      "hreflang": "EN",
      "meta": {
        "media": "media",
        "name": "name"
      }
    }
  }
}

As you can see, the properties title, type, and hreflang now appear only as top-level link properties. The new format reflects the JSON:API 1.1 link structure, but is not backward compatible with version 1.x.x of this library.

If you want to maintain backward compatibility and render the link properties type, title, and hreflang both as top-level link properties and in the meta section, you can configure this behavior (see [links-configuration]). The rendered result would then look like:

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    }
  },
  "links": {
    "self": {
      "href": "https://complex-links.org",
      "title": "title",
      "type": "type",
      "hreflang": "EN",
      "meta": {
        "hreflang": "EN",
        "media": "media",
        "title": "title",
        "type": "type",
        "name": "name"
      }
    }
  }
}

JSON:API is very strict about the allowed link relations, the allowed top-level links are self, related, describedBy, next, prev, first and last. The only allowed resource link is self.

By default, all other links will not be serialized. If you want to serialize links that are non-compliant with JSON:API, you can use a specific configuration, (see [links-configuration]).

The JSON:API specification allows links to be placed at two different levels in the document:

  • Document level (top-level) - Links appear at the top level of the JSON document (default behavior)

  • Resource level - Links appear inside the resource object within the "data" section

By default, this library places links at the document level for single resource (EntityModel) serialization. However, you can configure this behavior using the LinksAtResourceLevel configuration option (see [links-placement-configuration]).

When using the default configuration, links from an EntityModel are placed at the document level:

Movie movie = new Movie("1", "Star Wars");
EntityModel<Movie> entityModel = EntityModel.of(movie);
entityModel.add(Link.of("http://localhost/movies/1").withSelfRel());

This produces the following JSON:

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    }
  },
  "links": {
    "self": "http://localhost/movies/1"
  }
}

To place links at the resource level instead, configure LinksAtResourceLevel to true (See Link Placement Configuration Example):

Movie movie = new Movie("1", "Star Wars");
EntityModel<Movie> entityModel = EntityModel.of(movie);
entityModel.add(Link.of("http://localhost/movies/1").withSelfRel());

This produces the following JSON:

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    },
    "links": {
      "self": "http://localhost/movies/1"
    }
  }
}
This configuration only affects single resource (EntityModel) serialization. Collection models (CollectionModel) and paged models (PagedModel) are not affected by this setting.

Both approaches are valid according to the JSON:API specification. Choose the approach that best fits your API design:

  • Document level - Common pattern, provides clear separation between document metadata and resource data

  • Resource level - Keeps all resource-related information co-located, which some API consumers may prefer

4.4. Annotations

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

This project provides four annotations:

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

  • @JsonApiMeta to mark a field or method to provide a JSON:API meta information

    • This annotation works for serialization and deserialization.

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 (jakarta.persistence.Id) is used on a field

  • the annotation @Id (jakarta.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 MovieWithAnnotations {

  @Id private String myId;

  @JsonApiType private String type;

  @JsonApiMeta private String myMeta;

  private String title;
}

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

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

4.5. 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 to always be 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 a 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 the 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%5Bnumber%5D=0&page%5Bsize%5D=10",
    "prev": "http://localhost/movies?page%5Bnumber%5D=0&page%5Bsize%5D=10",
    "next": "http://localhost/movies?page%5Bnumber%5D=2&page%5Bsize%5D=10",
    "last": "http://localhost/movies?page%5Bnumber%5D=9&page%5Bsize%5D=10"
  },
  "meta": {
    "page": {
      "size": 10,
      "totalElements": 100,
      "totalPages": 10,
      "number": 1
    }
  }
}

4.5.1. Explicit Configuration of Empty Relationships

The JSON:API specification allows empty to-one relationships and empty to-many relationships (see JSON:API specification).

For more explicit control over the serialization of empty relationships, you can use relationshipWithNullData() to create an empty to-one relationship (serialized as "data": null) or relationshipWithEmptyData() to create an empty to-many relationship (serialized as "data": []).

An explicit empty to-one relationship can be added like:

final RepresentationModel<?> jsonApiModel =
    jsonApiModel().model(EntityModel.of(movie)).relationshipWithNullData("directors").build();

This will produce "data": null in the relationship object, representing an empty to-one relationship.

An explicit empty to-many relationship can be added like:

final RepresentationModel<?> jsonApiModel =
    jsonApiModel().model(EntityModel.of(movie)).relationshipWithEmptyData("directors").build();

This will produce "data": [] in the relationship object, representing an empty to-many relationship.

These methods are particularly useful when you want to replace existing relationship data with null or an empty array, or when you need to explicitly indicate the type of empty relationship (to-one vs. to-many) for your JSON:API consumers. These methods also preserve any existing links and meta information in the relationship.

4.6. Inclusion of Related Resources

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

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

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

if (include != null && include.length == 1 && include[0].equals(DIRECTORS)) {
  for (Movie movie : pagedResult.getContent()) {
    jsonApiModelBuilder.included(movie.getDirectors());
  }
}

Duplicated included directors will be eliminated automatically.

4.7. Nesting of JsonApiModels

When using the model builder, JsonApiModel instances can be used as the 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"
  }
}

4.8. Sparse Fieldsets

Sparse fieldsets are supported for attributes within data and included. You can add sparse fieldsets using the JsonApiBuilder. The following example illustrates the build, assuming a director has the attributes name and born, and a movie has 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 can provide an optional request parameter 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());
}

4.9. Spring HATEOAS Affordances

Spring HATEOAS provides a generic, media type-independent API for 3.0.1/reference/html/#server.affordances[affordances]. In the JSON:API specification, there is no equivalent concept, but JSON:API allows links to have additional meta information. This library provides a new experimental configuration to render Spring HATEOAS affordances as JSON:API link meta. Currently, a proprietary format is supported as well as the HAL-FORMS template format, which can be serialized by Spring HATEOAS out of the box.

The following example shows the usage of Spring HATEOAS affordances. First, you need to enable this experimental feature in the configuration, like:

new JsonApiConfiguration().withAffordancesRenderedAsLinkMeta(
   JsonApiConfiguration.AffordanceType.SPRING_HATEOAS);

Then you can add an affordance (for creating a movie) to a Spring HATEOAS link like:

final Affordance newMovieAffordance = afford(methodOn(MovieController.class).newMovie(null));

Link selfLink =
    linkTo(MovieController.class)
        .slash(
            "movies"
                + uriParams
                + "&page[number]="
                + pagedResult.getNumber()
                + "&page[size]="
                + pagedResult.getSize())
        .withSelfRel()
        .andAffordance(newMovieAffordance);

The rendered result of the self link would then be

"links": {
  "self": {
    "href": "http://localhost:8080/api/movies?page[number]=0&page[size]=10",
    "meta": {
      "affordances": [
        {
          "name": "newMovie",
          "link": {
            "rel": "newMovie",
            "href": "http://localhost:8080/api/movies"
          },
          "httpMethod": "POST",
          "inputProperties": [
            {
              "name": "imdbId",
              "type": "text",
            },
            {
              "name": "rank",
              "type": "number",
            },
            {
              "name": "rating",
              "type": "number",
            },
            {
              "name": "title",
              "type": "text",
              "required": true
            },
            {
              "name": "year",
              "type": "number",
            }
          ]
        }
      ]
    }
  }
}
This feature is experimental. The JSON structure of the provided affordances might have breaking changes in upcoming releases.
To obtain property information such as required fields, you must include a dependency on javax.validation:validation-api and annotate the required fields with @NotNull.

4.10. 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 simply need 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 must then 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 want to set the attributes of a related director, you could put the director resource in the included section of the JSON, like

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

In this case, after deserialization the name attribute of the first director is set to "George Lucas".

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.

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

4.12. Deserialization of PagedModels

While a server implementation of HTTP POST and PATCH takes single resources as input, it is sometimes useful to be able to deserialize collection models and paged models. This is helpful when a service consumes results from other services that produce JSON:API responses.

Here is an example of a serialized PagedModel.

{
  "data": [
    {
      "id": "1",
      "type": "movies",
      "attributes": {
        "title": "Star Wars"
      },
      "links": {
        "imdb": "https://www.imdb.com/title/tt0076759/?ref_=ttls_li_tt"
      }
    },
    {
      "id": "2",
      "type": "movies",
      "attributes": {
        "title": "Avengers"
      },
      "links": {
        "imdb": "https://www.imdb.com/title/tt0848228/?ref_=fn_al_tt_1"
      }
    }
  ],
  "links": {
    "next": "http://localhost/movies?page[number]=2&page[size]=2"
  },
  "meta": {
    "page": {
      "size": 2,
      "totalElements": 2,
      "totalPages": 2,
      "number": 1
    }
  }
}

If you deserialize the above JSON to a PagedModel<EntityModel<Movie>>>: The page meta information will be deserialized, as well as the links in both movie entity models. The same mechanism would work also for CollectionModel<EntityModel<Movie>>>.

4.13. UUID Deserialization

UUIDs (java.util.UUID) are supported natively for deserialization. So a JSON like

{
  "data": {
    "id": "00000000-0001-e240-0000-00002f08ba38",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    }
  }
}

would be correctly deserialized to an object of class

@Data
@NoArgsConstructor
@AllArgsConstructor
@With
@JsonApiTypeForClass("movies")

public class MovieWithUUID {

  private UUID id;
  private String title;
}

4.14. Polymorphic Deserialization

The easiest way for polymorphic deserialization is to use the JsonApiConfiguration to assign a JSON:API type to a Java class and then also enable the mappings to be used for deserialization, e.g.

@Bean
JsonApiConfiguration jsonApiConfiguration() {
    return new JsonApiConfiguration()
            .withTypeForClass(MovieSubclass.class, "my-movies")
            .withTypeForClassUsedForDeserialization(true));
}

Then a POST to a controller method like

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

with JSON like

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

would be deserialized to a Java class of type MovieSubclass. Note that this mechanism overrides the default deserialization to an object of the class given by the REST controller method’s signature. The deserializer checks if the mapped Java type is assignable to the originally required Java type; otherwise, an IllegalArgumentException is thrown.

The same mechanism can also be used to deserialize polymorphic relationships.

Consider the following class:

@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
@With

public class MovieWithDirectors extends Movie {

  @JsonApiType String myType = "movies";

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

and a JsonApiConfiguration that looks like

@Bean
JsonApiConfiguration jsonApiConfiguration() {
    return new JsonApiConfiguration()
        .withTypeForClass(DirectorWithEmail.class,  "directors-with-email")
        .withTypeForClassUsedForDeserialization(true));
}

Then an HTTP POST to /movies with body

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

would create 2 directors in the directors list, both empty except for the id field. But the first director would be an instance of class Director, while the second director would be an instance of class DirectorWithEmail.

4.14.1. Jackson Annotations

If the above mechanism does not fit your needs, you can also configure polymorphic deserialization on a per-class basis using 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 an 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 were introduced in JSON:API version 1.1, see jsonapi.org/format/#document-member-names-at-members. From the JSON:API 1.1 spec: …​an @-Member that occurs in an attributes object is not an attribute.

Important: The above mechanism is also used for serialization, so you can set the JSON:API type attribute (within data) to a more generic type while still serializing the @type attribute to indicate the specialized type. 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"
  }
}

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

@NoArgsConstructor
public class PolymorphicRelationEntity {

  @JsonApiId private String id;

  @JsonApiType private final String type = null;

  @JsonApiRelationships("superEntities")
  @JsonIgnore
  @Getter
  private final 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 final String type = null;

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

  @JsonApiId private String id;

  @JsonApiType private final String type = null;

  private final 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 a PolymorphicRelationEntity with 2 relationships, the first one of Class SuperEChild, the second one of class SuperEChild2.

Currently, there is a restriction that the type attribute of both SuperEChild and SuperEChild2 must be type. For example, _type would not work.

4.15. Error Handling

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

Here is an example of 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"
    }
  ]
}

4.15.1. More Generic Error Handling

If you want to implement more generic error handling that converts exceptions to JSON:API error responses, you can implement a @ControllerAdvice like:

@ControllerAdvice(annotations = RestController.class)
@Slf4j
public class ExceptionControllerAdvice {

  /** Handles all manually thrown exceptions of type JsonApiErrorsException. */
  @ExceptionHandler
  public ResponseEntity<JsonApiErrors> handle(JsonApiErrorsException ex) {
    log.error("JSON:API error: Http status:{}, message:{}", ex.getStatus(), ex.getMessage());
    return ResponseEntity.status(ex.getStatus()).body(ex.getErrors());
  }

  /**
   * Handles all exceptions which are not of type JsonAPIErrorsException and treats them as internal
   * errors or maps specific exceptions to specific HTTP status codes.
   */
  @ExceptionHandler
  public ResponseEntity<JsonApiErrors> handle(Exception ex) {
    log.error("Internal error: {}", ex.getMessage());
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .body(
            JsonApiErrors.create()
                .withError(CommonErrors.newInternalServerError().withDetail(ex.getMessage())));
  }
}

This would then convert exceptions to JSON:API error responses like:

{
    "errors": [
        {
            "id": "58bab604-a149-452a-ab30-f61fafab80e7",
            "status": "500",
            "code": "xrn:err:platform:internalServerError",
            "title": "Internal server error",
            "detail": "JSON parse error: Illegal unquoted character ((CTRL-CHAR, code 13)): has to be escaped using backslash to be included in string value"
        }
    ]
}

Furthermore, you can implement a JsonApiErrorsException like:

@Getter
public class JsonApiErrorsException extends RuntimeException {

  private final transient JsonApiErrors errors;
  private final HttpStatus status;

  public JsonApiErrorsException(JsonApiErrors errors, HttpStatus status) {
    super();
    this.errors = errors;
    this.status = status;
  }

  public JsonApiErrorsException(JsonApiError error) {
    super();
    this.errors = JsonApiErrors.create().withError(error);
    this.status = HttpStatus.valueOf(Integer.parseInt(error.getStatus()));
  }
}

and provide CommonErrors like:

public class CommonErrors {

  private CommonErrors() {}

  private static final JsonApiError resourceNotFound =
      JsonApiError.create()
          .withCode("xrn:err:platform:resourceNotFound")
          .withTitle("Resource Not Found")
          .withStatus("404");

  private static final JsonApiError badRequest =
      JsonApiError.create()
          .withCode("xrn:err:platform:badRequest")
          .withTitle("Bad Request")
          .withStatus("400");

  private static final JsonApiError internalServerError =
      JsonApiError.create()
          .withCode("xrn:err:platform:internalServerError")
          .withTitle("Internal server error")
          .withStatus("500");

  public static JsonApiError newResourceNotFound(String resourceType, String resourceId) {
    return resourceNotFound
        .withDetail(
            "Resource of type '" + resourceType + "' with id '" + resourceId + "' not found.")
        .withId(java.util.UUID.randomUUID().toString());
  }

  public static JsonApiError newBadRequestError(String message) {
    return badRequest.withId(java.util.UUID.randomUUID().toString()).withDetail(message);
  }

  public static JsonApiError newInternalServerError() {
    return internalServerError.withId(java.util.UUID.randomUUID().toString());
  }
}

Now you can throw a JsonApiErrorsException like:

.orElseThrow(
    () ->
        new JsonApiErrorsException(
            CommonErrors.newResourceNotFound("movies", id.toString())));

which would then be converted to a JSON:API error response.

You can find implementations of the ExceptionControllerAdvice, JsonApiErrorsException, and CommonErrors in the example code.

5. Client-Side Support

5.1. Deserialization

Simple JSON:API-based JSON structures can be deserialized, but only the generic Spring HATEOAS representation models are supported.

For example, a JSON structured like

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Star Wars"
    }
  },
  "links": {
    "self": "http://localhost/movies/1"
  }
}

would be deserialized to an object of class EntityModel<Movie>, where the Movie class looks like

@Data
@NoArgsConstructor
@AllArgsConstructor
@With

public class Movie {

  private String id;
  private String title;
}

Note that the deserialization mechanism is currently unable to deserialize all types of complex JSON:API structures that can be built with the JSON:API model builder, but several features are already supported:

  • Deserializing meta

  • Deserializing included resources into the relationship DTOs

For example, a JSON like

{
  "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%5Bnumber%5D=0&page%5Bsize%5D=10",
    "prev": "http://localhost/movies?page%5Bnumber%5D=0&page%5Bsize%5D=10",
    "next": "http://localhost/movies?page%5Bnumber%5D=2&page%5Bsize%5D=10",
    "last": "http://localhost/movies?page%5Bnumber%5D=9&page%5Bsize%5D=10"
  },
  "meta": {
    "page": {
      "size": 10,
      "totalElements": 100,
      "totalPages": 10,
      "number": 1
    }
  }
}

would be deserialized into a CollectionModel<EntityModel<MovieWithDirectors>>, so that all the names of the directors are set on the Java side.

CollectionModel<MovieWithDirectors> would NOT resolve the director names, because deserialization of JSON:API-specific features like relationships only works with Spring HATEOAS representation models.

For more examples of relationship deserialization, see the section Deserialization of JSON:API Types.

5.2. REST Clients

If you want to write a client that deserializes server responses into Java objects, you can use Spring’s HTTP client APIs with JSON:API support.

5.2.1. RestClient (Spring Boot 4+)

For Spring Boot 4 and later, use RestClient (which replaces RestTemplate):

@Configuration
public class JsonApiClientConfig {

    @Bean
    public RestClient jsonApiRestClient(JsonApiConfiguration jsonApiConfiguration) {
        JsonMapper jsonMapper = jsonApiConfiguration.getJsonMapper();

        // Register JSON:API module
        Jackson2JsonApiModule jackson2JsonApiModule =
            new Jackson2JsonApiModule(jsonApiConfiguration);
        jsonMapper.registerModule(jackson2JsonApiModule);

        return RestClient.builder()
            .baseUrl("http://localhost:8080")
            .defaultHeader("Accept", "application/vnd.api+json")
            .messageConverters(converters -> {
                MappingJackson2HttpMessageConverter converter =
                    new MappingJackson2HttpMessageConverter();
                converter.setObjectMapper(jsonMapper);
                converter.setSupportedMediaTypes(
                    List.of(MediaType.parseMediaType("application/vnd.api+json"))
                );
                converters.add(0, converter);
            })
            .build();
    }
}

5.2.2. RestTemplate (Legacy)

For older Spring Boot versions, you can use RestTemplate with JSON:API configuration:

@Configuration
@EnableHypermediaSupport(type = {})
static class Config {

  public @Bean RestTemplate template() {
    return new RestTemplate();
  }

  @Bean
  public JsonApiMediaTypeConfiguration jsonApiMediaTypeConfiguration(
      ObjectProvider<JsonApiConfiguration> configuration,
      AutowireCapableBeanFactory beanFactory) {
    return new JsonApiMediaTypeConfiguration(configuration, beanFactory);
  }
}
RestTemplate is in maintenance mode and will be removed in a future Spring Framework release. For new applications using Spring Boot 4+, use RestClient instead.

5.2.3. Creating POST requests without serialized JSON:API id

If you want to use RestTemplate to make a POST request, the JSON:API id is often created by the server and is not included in the JSON body. The easiest way to achieve this is to configure a marker value to indicate that DTOs with this id value should not include the JSON:API id in the resulting JSON.

For example, if you specify a configuration like

new JsonApiConfiguration().withJsonApiIdNotSerializedForValue("doNotSerialize"));

and create a Movie DTO with this id, like

Movie movie = new Movie("doNotSerialize", "Star Wars");

the resulting JSON would look like

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

5.3. Traverson

The hypermedia type application/vnd.api+json is currently not compatible with the Traverson implementation provided by Spring HATEOAS.

When working with hypermedia-enabled representations, a common task is to find a link with a particular relation type in it. Spring HATEOAS provides JsonPath-based implementations of the LinkDiscoverer interface for the configured hypermedia types. When using this library, an instance supporting this hypermedia type (application/vnd.api+json) is exposed as a Spring bean.

Alternatively, you can set up and use an instance as follows (where source is the exact JSON you saw in the Deserialization section):

LinkDiscoverer linkDiscoverer = new JsonApiLinkDiscoverer();
Links links = linkDiscoverer.findLinksWithRel(SELF, source);

assertThat(links.hasLink("self")).isTrue();
assertThat(links).map(Link::getHref).contains("http://localhost/movies/1");

6. Configuration

There are several options for customizing the JSON:API rendering output.

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

Configuration

Description

Default

JsonApiObject

Set a JSON:API object with all allowed properties, such as version, ext, profile, and meta.

not set

PluralizedTypeRendered

Whether JSON:API types should be rendered as pluralized or non-pluralized class names.

pluralized

LowerCasedTypeRendered

Whether JSON:API types should be rendered as lowercase or original class names.

lowercase

PageMetaAutomaticallyCreated

Whether page information of a PagedModel should be rendered automatically as JSON:API meta.

true

TypeForClass

Specify if a specific Java class should be rendered with a specific JSON:API type. This is useful when representation model classes should use the JSON:API type of the domain model or when derived classes should use the JSON:API type of the superclass.

not set

TypeForClassUsedForDeserialization

Whether the above "Java class to JSON:API type" mapping should also be used for deserialization. This is very useful for polymorphic use cases.

true

EmptyAttributesObjectSerialized

Whether empty attributes should be serialized as an empty JSON object, like "attributes": {}. If set to false, no "attributes" key is serialized when attributes are empty.

true

JsonApiIdNotSerializedForValue

A marker value that indicates the JSON:API id should not be serialized. This is useful when creating JSON for a POST request. See also Creating POST requests without serialized JSON:API id.

not set

MapperCustomizer

A lambda expression (UnaryOperator<JsonMapper.Builder>) to add additional configuration to the Jackson 3 JsonMapper.Builder used for serialization and deserialization. This allows customizing Jackson features, modules, or other settings. See JSON Mapper Customization.

not set

JsonApiCompliantLinks

The following links are JSON:API compliant: self, related, describedBy, next, prev, first, and last for top-level links. Only self is compliant for resource links. To allow any link, set this configuration to false.

true

JsonApi11LinkPropertiesRemovedFromLinkMeta

Whether Spring HATEOAS complex links should be serialized/deserialized in a backward-compatible (with version 1.x.x of this library) way. By default, the Spring HATEOAS properties title, type, and hreflang will be serialized only as top-level link properties. These link properties were introduced in JSON:API 1.1, see jsonapi.org/format/#auto-id—​link-objects. To serialize the Spring HATEOAS properties title, type, and hreflang both as top-level link properties and in the meta section, set this property to false. See also [links-configuration]. Note that using the default setting, Spring HATEOAS complex links are rendered in a backward-incompatible way (relative to version 1.x.x of this library which only supports JSON:API 1.0), as clients might expect properties like title in the meta section.

true

LinksNotUrlEncoded

Set of link relations which are not URL encoded when serializing.

empty set

LinksAtResourceLevel

Controls where links are placed in JSON:API documents for single resource (EntityModel) serialization. When set to true, links are placed at the resource level (inside the resource object in the "data" section). When set to false, links are placed at the document level (top-level). See also Link Placement.

false

Since the JSON:API recommendation uses square brackets in request parameter names, those brackets are usually URL-encoded to %5B and %5D. If you want your server to also accept raw [ and ] characters in the URL, add the following configuration to your Spring application.properties when using Tomcat: server.tomcat.relaxed-query-chars= [,]. When this library automatically creates pagination links, [ and ] characters are always URL-encoded.

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

@Bean
JsonApiConfiguration jsonApiConfiguration() {
  return new JsonApiConfiguration()
      .withJsonApiObject(new JsonApiObject(true))
      .withPluralizedTypeRendered(false)
      .withLowerCasedTypeRendered(false)
      .withTypeForClass(MovieRepresentationModelWithoutJsonApiType.class, "my-movies")
      .withTypeForClassUsedForDeserialization(true)
      .withEmptyAttributesObjectSerialized(false)
      .withJsonApiIdNotSerializedForValue("-1")
      .withJsonApi11LinkPropertiesRemovedFromLinkMeta(false)
      .withJsonApiCompliantLinks(false);
}

To configure links to appear at the resource level instead of the document level:

@Configuration
public class JsonApiConfig {

    @Bean
    public JsonApiConfiguration jsonApiConfiguration() {
        return new JsonApiConfiguration()
            .withLinksAtResourceLevel(true);  (1)
    }
}
1 Enable resource-level links for single resource (EntityModel) serialization

When this configuration is enabled, links from EntityModel are placed inside the resource object in the "data" section instead of at the document top level. See Link Placement for more details and examples.

6.2. JSON Mapper Customization

Starting with Version 3, this library uses Jackson 3 (package tools.jackson. instead of com.fasterxml.jackson.). You can customize the Jackson JsonMapper used for JSON:API serialization and deserialization through the mapperCustomizer configuration option.

The customizer is a UnaryOperator<JsonMapper.Builder> that receives the Jackson JsonMapper.Builder and returns a modified builder. This allows you to:

  • Enable or disable Jackson serialization/deserialization features

  • Register custom modules

  • Configure date/time formats

  • Add custom serializers or deserializers

  • Enable or disable pretty printing

6.2.1. Example: Basic Mapper Customization

@Configuration
public class JsonApiConfig {

    @Bean
    public JsonApiConfiguration jsonApiConfiguration() {
        return new JsonApiConfiguration()
            .withMapperCustomizer(builder ->
                builder.enable(SerializationFeature.INDENT_OUTPUT)  (1)
            );
    }
}
1 Enable pretty-printed JSON output

6.2.2. Example: Multiple Customizations

@Configuration
public class JsonApiConfig {

    @Bean
    public JsonApiConfiguration jsonApiConfiguration() {
        return new JsonApiConfiguration()
            .withMapperCustomizer(builder -> builder
                .enable(SerializationFeature.INDENT_OUTPUT)  (1)
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)  (2)
                .enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)  (3)
            );
    }
}
1 Enable pretty-printed JSON output
2 Don’t fail when encountering unknown properties during deserialization
3 Serialize dates as timestamps instead of ISO-8601 strings

Jackson 3 Migration Notes:

  • Use tools.jackson.databind. packages instead of com.fasterxml.jackson.databind.

  • JsonMapper replaces ObjectMapper as the preferred API

  • Serializers and deserializers must use Jackson 3 base classes from tools.jackson.*

  • Annotations remain in com.fasterxml.jackson.annotation.* (unchanged)

Accessing the Configured Mapper

You can obtain a fully configured JsonMapper instance from the configuration:

JsonApiConfiguration config = new JsonApiConfiguration()
    .withMapperCustomizer(builder ->
        builder.enable(SerializationFeature.INDENT_OUTPUT)
    );

JsonMapper mapper = config.getJsonMapper();  (1)
1 Returns a JsonMapper with all customizations applied