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
        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 @Masked fields and returns a list of MaskedFieldInfo. This metadata is later available in visitors via pluginInfo.getResourcePluginInfo().
  • 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, 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.