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
Class<?> attributesClass = Arrays.stream(resource.getClass().getMethods())
.filter(m -> m.getName().equals(Resource.RESOLVE_ATTRIBUTES_METHOD_NAME) && !m.isBridge())
.map(Method::getReturnType)
.findFirst()
.orElse(null);
if (attributesClass == null) {
return null;
}
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 viactx.getPluginInfo().getResourcePluginInfo().- The
!m.isBridge()filter matters:resolveAttributesoverrides a generic interface method, so the compiler also generates a bridge method returningObject. Picking that one would find no@Maskedfields. 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, ATTRIBUTES>
RelationshipsPostRetrievalPhase<?> onRelationshipsPostRetrieval(
SingleResourceVisitorContext<REQUEST, DATA_SOURCE_DTO, ATTRIBUTES> ctx) {
if (ctx.getDoc() != null && ctx.getDoc().getData() != null) {
maskAttributes(ctx.getDoc().getData().getAttributes(), ctx.getPluginInfo());
}
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, ATTRIBUTES>
RelationshipsPostRetrievalPhase<?> onRelationshipsPostRetrieval(
MultipleResourcesVisitorContext<REQUEST, DATA_SOURCE_DTO, ATTRIBUTES> ctx) {
if (ctx.getDoc() != null && ctx.getDoc().getData() != null) {
ctx.getDoc().getData().forEach(resourceObject ->
FieldMaskingSingleResourceVisitors.maskAttributes(
resourceObject.getAttributes(), ctx.getPluginInfo()
)
);
}
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();
}
}Build a PluginRegistry and pass the same registry to the DomainRegistry and OperationsRegistry builders — plugin metadata is extracted while those registries are built. Then publish the registries from a ServletContextListener, as shown in Deployment.
PluginRegistry plugins = PluginRegistry.builder()
.register(new FieldMaskingPlugin())
.build();
DomainRegistry domainRegistry = DomainRegistry.builder(plugins)
.resource(new UserResource())
.build();
OperationsRegistry operationsRegistry = OperationsRegistry.builder(plugins)
.operations(new UserOperations())
.build();
servletContext.setAttribute(PLUGIN_REGISTRY_ATT_NAME, plugins);
servletContext.setAttribute(DOMAIN_REGISTRY_ATT_NAME, domainRegistry);
servletContext.setAttribute(OPERATION_REGISTRY_ATT_NAME, operationsRegistry);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.
Giving the Plugin Configuration
A plugin that reads settings implements PluginProperties, which names the jsonapi4j.* subtree it owns:
public interface FieldMaskingProperties extends PluginProperties {
String FM_PROPERTY = "fm";
String MASK_CHARACTER_PROPERTY = "maskCharacter";
@Override
default String section() {
return FM_PROPERTY; // binds jsonapi4j.fm.*
}
default boolean enabled() {
return true;
}
default String maskCharacter() {
return "*";
}
}
Return it from the plugin’s configProperties(). Two things follow automatically: the effective values show up in
the config meta resource, and the section takes part in startup
validation.
To validate it, override validate() and build a result with the shared vocabulary — requireNotBlank,
requirePositive, requireHttpUrl, requireOneOfIgnoringCase, requireServletPath:
@Override
default PropertiesValidationResult validate() {
if (!enabled()) {
return PropertiesValidationResult.empty(); // parked config never breaks a boot
}
return PropertiesValidationResult.builder()
.requireNotBlank(propertyPath(MASK_CHARACTER_PROPERTY), maskCharacter())
.build();
}
propertyPath(...) prefixes your section, so the error points at jsonapi4j.fm.maskCharacter — the exact key the
user wrote. A misconfigured plugin fails the boot with every error in the section at once.
If a setting has to agree with the root configuration, override validateAgainst(JsonApi4jProperties) instead;
it receives the effective jsonapi4j.rootPath, validation limits and meta settings.
The Plugin Registry
Registered plugins are assembled into a PluginRegistry, reachable from the framework instance as
jsonApi4j.getPluginRegistry():
| Method | Use |
|---|---|
getAllPlugins() |
every plugin, enabled or not — introspection and reporting |
getActivePlugins() |
enabled only, in the order they run — precedence, then plugin name |
isActivePlugin(name) |
react to a peer plugin without depending on it (matched by name, as the OpenAPI plugin does for sparse fieldsets) |
configOf(SomeProperties.class) |
another plugin’s bound configuration, by type |
Two rules the registry enforces at startup: plugin names must be unique (the name keys the metadata your
plugin extracts, and the id of its plugins meta resource) and config sections must be unique.
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 / PluginRegistry |
Makes the framework aware of your plugin |
| Configuration (optional) | PluginProperties + validate() |
Binds jsonapi4j.<section>.*, publishes it to the config meta resource, and validates it at startup |
For more on how the pipeline stages work, see Plugin System and Request Processing Pipeline.