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:

  1. Define a @Masked annotation to mark sensitive fields
  2. Extract annotation metadata at registration time
  3. 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 @Masked fields and returns a list of MaskedFieldInfo. This metadata is later available in visitors via ctx.getPluginInfo().getResourcePluginInfo().
  • The !m.isBridge() filter matters: resolveAttributes overrides a generic interface method, so the compiler also generates a bridge method returning Object. Picking that one would find no @Masked fields.
  • singleResourceVisitors() and multipleResourcesVisitors() 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.