A JSON:API library for Micronaut implementations.
- Entity mapping to JSON:API resource(s)
- A processor for an "include" query parameter. E.g.
?include=author.address,publishingCompany
- Error response processor
- Page and Slice responses
- Deserializing "data" fields into corresponding JsonApiResource or JsonApiArray
- Transform JSON:API resource(s) into other Java classes, such as entities
dependencies {
implementation("io.github.baylorpaul:micronaut-json-api:2.0.0")
}
In your application.properties
, set:
micronaut.serde.serialization.inclusion=non_absent
This will continue omitting null and Optional.empty(), but for the JSON:API spec, include empty collections.
I.e. it is desirable to have "attributes":{}
instead of excluding attributes
.
For entities you plan to expose to the API, add implements JsonApiResourceable
to the class. And then add the appropriate methods. E.g.
@MappedEntity
@Data
@Builder(toBuilder = true)
@Serdeable.Deserializable
@NoArgsConstructor
@AllArgsConstructor
@ReflectiveAccess
public class Article implements JsonApiResourceable {
private @Id @GeneratedValue @NonNull long id;
private @Relation(Relation.Kind.MANY_TO_ONE) User author;
private @Relation(Relation.Kind.ONE_TO_MANY) List<PhysicalAddress> addresses;
@Override
public String toResourceType() {
return "article";
}
@Override
public String toJsonApiId() {
return Long.toString(id);
}
@Override
public void applyJsonApiId(String jsonApiId) {
setId(jsonApiId == null ? 0L : Long.parseLong(jsonApiId));
}
@Override
public SequencedMap<String, Object> toJsonApiAttributes() {
return new LinkedHashMap<>();
}
@Override
public SequencedMap<String, ? extends JsonApiDataTypeable> toRelationships() {
return new TreeMap<>(Map.of(
"addresses", new JsonApiArrayable(addresses),
"author", author
));
}
}
See the samples in the test packages, such as User.java
or GrantingToken.java
. For entities that you want to expose to
your API:
- Implement the JsonApiResourceable interface on your entity.
- Ensure the value in
toResourceType()
is unique. - Expose the publicly accessible attributes in the
toJsonApiAttributes()
method. - If applicable, implement the
toRelationships()
method. - In your controller method, find the result, and map it to a
JsonApiTopLevelResource
viaJsonApiResourceable::toTopLevelResource
. E.g.@Secured(SecurityRule.IS_AUTHENTICATED) @Get("/users/{id}") public Optional<JsonApiTopLevelResource> show(long id, Principal principal) { if (id != SecurityUtil.requireUserId(principal)) { throw new HttpStatusException(HttpStatus.NOT_FOUND, "id does not match the requesting user"); } return userRepo.findById(id) .map(JsonApiResourceable::toTopLevelResource); }
As documented in JSON:API's Inclusion of Related Resources, "an endpoint MAY also support an include
query parameter to allow the client to customize which related resources should be returned."
To fulfill this, use JsonApiIncludeProcessor
. E.g.
List<RelationshipRetriever> supportedIncludePaths = buildEmptyRelationshipRetrievers(
"author", "author.address", "publishingCompany"
);
JsonApiIncludeProcessor includeProcessor = new JsonApiIncludeProcessor(includeQueryParameter, supportedIncludePaths);
In a controller method, you may e.g.
@Secured(SecurityRule.IS_AUTHENTICATED)
@Get("/grantingTokens/{?filterOptions*}")
public JsonApiPage<GrantingToken> getGrantingTokens(
Principal principal,
@Valid GrantingTokenFilterOptions filterOptions,
@Valid Pageable pageable,
@Nullable String include
) {
// Don't allow integration tokens to view tokens
SecurityUtil.throwIfNotAccessTokenAuthorization(principal);
// In this example, we don't support any values for "include"
List<RelationshipRetriever> supportedIncludePaths = null;
final GrantingTokenFilter filter = filterOptions.buildFilterAsPermitted(principal);
final Page<GrantingToken> page = grantingTokenRepo.getGrantingTokens(filter, pageable);
JsonApiIncludeProcessor includeProcessor = new JsonApiIncludeProcessor(include, supportedIncludePaths);
return new JsonApiPage<>(page, includeProcessor::findIncluded);
}
The @Body
may be mapped to an entity or a different DTO.
@Secured(SecurityRule.IS_ANONYMOUS) // anyone can make a new user
@Post("/users")
@Status(HttpStatus.CREATED)
public JsonApiTopLevelResource create(@Body JsonApiObject<JsonApiResource> body) {
UserCreation dto = JsonApiUtil.readDataWithoutId(jsonMapper, body, UserCreation.class);
User newUser = userRestService.createUser(dto);
return newUser.toTopLevelResource();
}
In the previous example, UserCreation
is the following, but you can also map to your entity:
@Data
@Serdeable
@ReflectiveAccess
public class UserCreation {
private @NotBlank String email;
private @Nullable String name;
private @NotBlank String password;
}
@Secured(SecurityRule.IS_AUTHENTICATED)
@Post("/grantingTokens")
@Status(HttpStatus.CREATED)
public JsonApiTopLevelResource create(Principal principal, @Body JsonApiObject<JsonApiResource> body) {
// Don't allow integration tokens to create other tokens
SecurityUtil.throwIfNotAccessTokenAuthorization(principal);
long userId = SecurityUtil.requireUserId(principal);
GrantingToken dto = JsonApiUtil.readDataWithoutId(jsonMapper, body, GrantingToken.class);
TokenCreation tokenCreation = grantingTokenRestService.createToken(userId, dto);
JsonApiResource res = tokenCreation.grantingToken().toResource();
// Include the signed token when it is created, but never again
res.putAllAttributes(new TreeMap<>(Map.of("signedToken", tokenCreation.signedToken())));
return JsonApiTopLevelResource.topLevelResourceBuilder()
.data(res)
.build();
}
@Secured(SecurityRule.IS_AUTHENTICATED)
@Patch("/users/{id}")
public Optional<JsonApiTopLevelResource> update(long id, Principal principal, @Body JsonApiObject<JsonApiResource> body) {
return JsonApiUtil.readAndValidateLongId(body, id)
.map(bodyId -> bodyId.longValue() == SecurityUtil.requireUserId(principal) ? bodyId : null)
.flatMap(bodyId -> userRepo.findById(bodyId))
.map(user -> userRestService.updateUser(user, body.getData()))
.map(JsonApiResourceable::toTopLevelResource);
}
Inside updateUser()
, you may map the provided JsonApiResource
to an entity or DTO via the following as long as res.getAttributes()
is not null:
User dto = JsonApiUtil.readValue(jsonMapper, res.getAttributes(), User.class);
@Secured(SecurityRule.IS_AUTHENTICATED)
@Delete("/grantingTokens/{id}")
public HttpResponse<?> delete(long id, Principal principal) {
// delete the record
}