Writing a Custom Plugin
This guide walks through building a custom JsonApi4j plugin from scratch. By the end, you’ll have a working Field Masking Plugin that redacts sensitive attributes (emails, phone numbers) in API responses based on a custom annotation.
The finished plugin will:
- Define a
@Maskedannotation to mark sensitive fields - Extract annotation metadata at registration time
- Hook into the response pipeline to mask values before they reach the client
Step 1: Define the Annotation
Create a custom annotation that marks which attribute fields should be masked:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Masked {
/**
* Number of characters to keep visible at the start.
* For example, 3 would turn "john@example.com" into "joh***".
*/
int keepFirst() default 0;
/**
* Number of characters to keep visible at the end.
* For example, 4 would turn "john@example.com" into "***e.com".
* Combine with keepFirst: "joh***com".
*/
int keepLast() default 0;
}
Apply it to your attributes class:
@Data
public class UserAttributes {
private String firstName;
private String lastName;
@Masked(keepFirst = 3, keepLast = 0)
private String email;
@Masked(keepFirst = 0, keepLast = 4)
private String phone;
}
With this configuration, john@example.com becomes joh*** and +1-555-123-4567 becomes ***4567.
Step 2: Create the Plugin Info Model
The plugin needs to store which fields are masked and how. Create a simple model that holds this metadata per resource:
@Data
public class MaskedFieldInfo {
private final String fieldName;
private final int keepFirst;
private final int keepLast;
}
Step 3: Implement JsonApi4jPlugin
The plugin class is the entry point. It tells the framework:
- What metadata to extract at registration time (
extractPluginInfoFromResource) - Which pipeline stages to hook into (visitor methods)
public class FieldMaskingPlugin implements JsonApi4jPlugin {
@Override
public String pluginName() {
return "field-masking";
}
@Override
public int precedence() {
// Run after most plugins but before response serialization
return LOW_PRECEDENCE;
}
@Override
public Object extractPluginInfoFromResource(Resource<?> resource) {
// Find the attributes class by inspecting the resolveAttributes method
Method method = ReflectionUtils.findMethod(
resource.getClass(),
Resource.RESOLVE_ATTRIBUTES_METHOD_NAME
);
if (method == null) {
return null;
}
Class<?> attributesClass = method.getReturnType();
List<MaskedFieldInfo> maskedFields = new ArrayList<>();
for (Field field : attributesClass.getDeclaredFields()) {
Masked masked = field.getAnnotation(Masked.class);
if (masked != null) {
maskedFields.add(new MaskedFieldInfo(
field.getName(), masked.keepFirst(), masked.keepLast()
));
}
}
return maskedFields.isEmpty() ? null : maskedFields;
}
@Override
public SingleResourceVisitors singleResourceVisitors() {
return new FieldMaskingSingleResourceVisitors();
}
@Override
public MultipleResourcesVisitors multipleResourcesVisitors() {
return new FieldMaskingMultipleResourcesVisitors();
}
}
Key points:
extractPluginInfoFromResource()runs once at startup for each registered resource. It scans the attributes class for@Maskedfields and returns a list ofMaskedFieldInfo. This metadata is later available in visitors viapluginInfo.getResourcePluginInfo().singleResourceVisitors()andmultipleResourcesVisitors()return visitor implementations that handle the actual masking.
Step 4: Implement the Visitors
Visitors hook into the request processing pipeline. For masking, we use RelationshipsPostRetrievalPhase — the last stage before the response is serialized. At this point, the full JSON:API document is assembled and we can modify attribute values.
Single Resource Visitor
Handles GET /users/{id} responses:
class FieldMaskingSingleResourceVisitors implements SingleResourceVisitors {
@Override
public <REQUEST, DATA_SOURCE_DTO, DOC extends SingleResourceDoc<?>>
RelationshipsPostRetrievalPhase<?> onRelationshipsPostRetrieval(
REQUEST request,
OperationMeta operationMeta,
DATA_SOURCE_DTO dataSourceDto,
DOC doc,
SingleResourceJsonApiContext<REQUEST, DATA_SOURCE_DTO, ?> context,
JsonApiPluginInfo pluginInfo) {
if (doc != null && doc.getData() != null) {
maskAttributes(doc.getData().getAttributes(), pluginInfo);
}
return RelationshipsPostRetrievalPhase.doNothing();
}
@SuppressWarnings("unchecked")
static void maskAttributes(Object attributes, JsonApiPluginInfo pluginInfo) {
List<MaskedFieldInfo> maskedFields =
(List<MaskedFieldInfo>) pluginInfo.getResourcePluginInfo();
if (maskedFields == null || attributes == null) {
return;
}
for (MaskedFieldInfo info : maskedFields) {
if (!ReflectionUtils.fieldPathExists(attributes, info.getFieldName())) {
continue;
}
Object value = ReflectionUtils.getFieldValueThrowing(attributes, info.getFieldName());
if (value instanceof String str) {
ReflectionUtils.setFieldPathValueSilent(attributes, info.getFieldName(),
mask(str, info.getKeepFirst(), info.getKeepLast()));
}
}
}
private static String mask(String value, int keepFirst, int keepLast) {
if (value.length() <= keepFirst + keepLast) {
return value;
}
String prefix = value.substring(0, keepFirst);
String suffix = keepLast > 0 ? value.substring(value.length() - keepLast) : "";
return prefix + "***" + suffix;
}
}
Multiple Resources Visitor
Handles GET /users responses — applies the same masking to every resource in the list:
class FieldMaskingMultipleResourcesVisitors implements MultipleResourcesVisitors {
@Override
public <REQUEST, DATA_SOURCE_DTO, DOC extends MultipleResourcesDoc<?>>
RelationshipsPostRetrievalPhase<?> onRelationshipsPostRetrieval(
REQUEST request,
OperationMeta operationMeta,
PaginationAwareResponse<DATA_SOURCE_DTO> paginationAwareResponse,
DOC doc,
MultipleResourcesJsonApiContext<REQUEST, DATA_SOURCE_DTO, ?> context,
JsonApiPluginInfo pluginInfo) {
if (doc != null && doc.getData() != null) {
doc.getData().forEach(resourceObject ->
FieldMaskingSingleResourceVisitors.maskAttributes(
resourceObject.getAttributes(), pluginInfo
)
);
}
return RelationshipsPostRetrievalPhase.doNothing();
}
}
Step 5: Register the Plugin
Register the plugin as a Spring bean. The framework auto-discovers all JsonApi4jPlugin beans.
@Configuration
public class PluginConfig {
@Bean
public JsonApi4jPlugin fieldMaskingPlugin() {
return new FieldMaskingPlugin();
}
}Provide the plugin as a CDI bean.
public class PluginConfig {
@Produces
@Singleton
public JsonApi4jPlugin fieldMaskingPlugin() {
return new FieldMaskingPlugin();
}
}Pass the plugin to the JsonApi4j builder.
JsonApi4j jsonApi4j = JsonApi4j.builder()
.domainRegistry(domainRegistry)
.operationsRegistry(operationsRegistry)
.plugins(List.of(new FieldMaskingPlugin()))
.build();Result
With the plugin registered, API responses automatically mask annotated fields:
{
"data": {
"type": "users",
"id": "1",
"attributes": {
"firstName": "John",
"lastName": "Doe",
"email": "joh***",
"phone": "***4567"
}
}
}
No changes to your operations, resources, or domain model — just the annotation and the plugin.
Recap
| Step | What You Implement | Purpose |
|---|---|---|
| Annotation | @Masked |
Declarative configuration on attribute fields |
| Plugin info model | MaskedFieldInfo |
Carries annotation metadata through the pipeline |
| Plugin class | FieldMaskingPlugin |
Entry point — extracts metadata and provides visitors |
| Visitors | SingleResourceVisitors, MultipleResourcesVisitors |
Hooks into the pipeline to transform the response |
| Registration | Spring @Bean / Quarkus @Produces / Builder |
Makes the framework aware of your plugin |
For more on how the pipeline stages work, see Plugin System and Request Processing Pipeline.