Table of Contents

Introduction

Spring Data Web Spec maps HTTP request data to Spring Data JPA Specification objects directly in Spring MVC controller method signatures.

Use it when a search endpoint needs filters from query parameters, path variables, headers, JSON body fields, or application-specific context such as tenant or access-control rules.

@GetMapping("/users")
public Page<UserDto> findUsers(
        @Spec.Param(name = "role", attribute = "role.name", operator = In.class)
        @Spec.Param(name = "name", operator = ContainsIgnoreCase.class)
        Specification<User> spec,
        Pageable pageable
) {
    return userRepository.findAll(spec, pageable).map(userMapper::toDto);
}

A request such as:

GET /users?role=ADMIN&role=MANAGER&name=alex

is converted to a JPA specification equivalent to:

role.name IN ('ADMIN', 'MANAGER') AND name LIKE '%alex%'

Source code is available on GitHub.

Current documentation covers Spring Data Web Spec 1.1.x for Spring Boot 4 / Spring Framework 7 projects. If your project still uses Spring Boot 3 / Spring Framework 6, use the archived Spring Data Web Spec v1.0.x documentation.

Why It Exists

Search and filtering endpoints often start simple and then accumulate query-parameter parsing, conditional joins, access-control predicates, and ad hoc query-building code.

Spring Data Web Spec lets controllers describe dynamic search APIs declaratively, mapping request data and access rules into reusable Spring Data JPA Specifications.

Installation

<dependency>
    <groupId>com.cariochi.spec</groupId>
    <artifactId>spring-data-web-spec</artifactId>
    <version>1.1.0</version>
</dependency>

Requirements

  • Java 21+
  • Spring Boot 4.0.x, or plain Spring Framework 7.0.x
  • Spring MVC 7.0.x
  • Spring Data JPA 4.0.x
  • Jakarta Servlet stack

Spring Boot applications are auto-configured when the library is on the classpath.

Configuration

Auto-configuration is enabled by default:

cariochi.spec.enabled=true

Disable it when you want to register the infrastructure yourself:

cariochi.spec.enabled=false

For plain Spring MVC or manual registration:

@Configuration
@EnableWebSpec
public class WebConfig {
}

If @Spec.Body is used together with a regular @RequestBody parameter, enable repeatable body reads:

cariochi.spec.body-repeatable=true

Core Model

All source annotations create small Specification<?> fragments. Without an explicit expression, all resolved fragments are combined with AND.

A specification parameter can be declared as either:

Specification<User> spec

or:

Optional<Specification<User>> spec

Use Optional<Specification<T>> when your repository code wants to distinguish “no filters” from “some filters”.

All source annotations share these attributes:

Attribute Description
name External input name: query parameter, path variable, header, body key, or custom resolver key.
attribute Entity attribute path. Defaults to name when omitted. Supports dotted paths such as organization.region.
operator Operator class. Defaults to Equal.
required Fails argument resolution when the value is missing or empty. Defaults to false.
distinct Applies distinct to the Criteria query when this condition is used.
joinType Join type used when traversing associations. Defaults to INNER.

Query Parameters

Use @Spec.Param for HTTP query parameters.

@GetMapping("/projects")
public Page<ProjectDto> findProjects(
        @Spec.Param(name = "status", operator = In.class)
        @Spec.Param(name = "name", operator = ContainsIgnoreCase.class)
        Specification<Project> spec,
        Pageable pageable
) {
    return projectRepository.findAll(spec, pageable).map(projectMapper::toDto);
}

Multiple query values are supported:

GET /projects?status=ACTIVE&status=PAUSED

Comma-separated values are also handled by Spring conversion for collection operators:

GET /projects?status=ACTIVE,PAUSED

Path Variables

Use @Spec.Path for URI template variables.

@GetMapping("/organizations/{organizationId}/projects")
public Page<ProjectDto> findProjects(
        @Spec.Path(name = "organizationId", attribute = "organization.id")
        @Spec.Param(name = "status", operator = In.class)
        Specification<Project> spec,
        Pageable pageable
) {
    return projectRepository.findAll(spec, pageable).map(projectMapper::toDto);
}

Headers

Use @Spec.Header for HTTP headers. This is useful for contextual filters such as region, tenant, client, or locale.

@GetMapping("/projects")
public Page<ProjectDto> findProjects(
        @Spec.Header(name = "X-Region", attribute = "organization.region", operator = In.class)
        @Spec.Param(name = "status", operator = In.class)
        Specification<Project> spec,
        Pageable pageable
) {
    return projectRepository.findAll(spec, pageable).map(projectMapper::toDto);
}

JSON Body Fields

Use @Spec.Body for JSON request bodies.

@PostMapping("/projects/search")
public Page<ProjectDto> searchProjects(
        @Spec.Body(name = "filters.id", attribute = "id")
        @Spec.Body(name = "filters.name", attribute = "name", operator = ContainsIgnoreCase.class)
        @Spec.Body(name = "filters.status", attribute = "status", operator = In.class)
        Specification<Project> spec,
        @RequestBody SearchRequest request
) {
    return projectRepository.findAll(spec, request.pageable()).map(projectMapper::toDto);
}

Example body:

{
  "filters": {
    "id": 42,
    "name": "billing",
    "status": ["ACTIVE", "PAUSED"]
  },
  "page": 0,
  "size": 20
}

Body keys support dot notation. Literal dotted keys are checked before nested object traversal, so both forms work:

{ "filters.status": ["ACTIVE"] }
{ "filters": { "status": ["ACTIVE"] } }

Servlet request bodies are normally single-read. If @Spec.Body and @RequestBody are used in the same handler, enable:

cariochi.spec.body-repeatable=true

Custom Conditions

Use @Spec.Condition when a value comes from application code instead of the HTTP request. This is a good fit for tenant isolation, permissions, user-scoped regions, or other access-control filters.

@GetMapping("/projects")
public Page<ProjectDto> findProjects(
        @Spec.Condition(attribute = "organization.region", resolver = UserAllowedRegions.class, operator = In.class)
        @Spec.Param(name = "status", operator = In.class)
        Specification<Project> spec,
        Pageable pageable
) {
    return projectRepository.findAll(spec, pageable).map(projectMapper::toDto);
}

The resolver is a Function<String, ?>. It can be a Spring bean; if no bean exists, Spring creates it through the bean factory.

@Component
@RequiredArgsConstructor
public class UserAllowedRegions implements Function<String, Set<String>> {

    private final UserService userService;

    @Override
    public Set<String> apply(String name) {
        return userService.getAllowedRegions();
    }
}

Expressions

Use @Spec.Expression when the default AND combination is not enough.

@GetMapping("/projects")
public Page<ProjectDto> findProjects(
        @Spec.Param(name = "id")
        @Spec.Param(name = "name", operator = ContainsIgnoreCase.class)
        @Spec.Param(name = "status", operator = In.class)
        @Spec.Param(name = "labels", operator = In.class)
        @Spec.Expression("(id || name) && (status || labels)")
        Specification<Project> spec,
        Pageable pageable
) {
    return projectRepository.findAll(spec, pageable).map(projectMapper::toDto);
}

Supported operators:

Logical operator Symbolic form Text form
AND && AND
OR || OR
NOT ! NOT

Parentheses are supported.

Conditions declared on the parameter but not referenced by the expression are still included and combined with AND.

By default, expressions are lenient: a missing condition alias evaluates as an absent predicate. To fail when an expression references an unknown or missing alias:

@Spec.Expression(value = "(id || name) && status", strict = true)

Operators

Built-in operators:

Category Operators
Equality Equal, NotEqual
Membership In, NotIn
String matching Contains, ContainsIgnoreCase, StartsWith, StartsWithIgnoreCase
Null checks IsNull, IsNotNull
Comparison GreaterThan, GreaterThanOrEqualTo, LessThan, LessThanOrEqualTo

Custom Operators

Implement Operator directly when you need full control, or implement BaseOperator for the common case where you only need to build a predicate for an attribute and converted value.

public class EndsWithIgnoreCase<T> implements BaseOperator<T, String, String> {

    @Override
    public Specification<T> buildSpecification(SpecAttribute<T, String> attribute, SpecValue<String> specValue) {
        return (root, query, cb) -> {
            Path<String> path = attribute.resolve(root);
            String value = specValue.convertTo(String.class);
            return cb.like(cb.lower(path), "%" + value.toLowerCase());
        };
    }
}

Then use it in an annotation:

@Spec.Param(name = "emailDomain", attribute = "email", operator = EndsWithIgnoreCase.class)
Specification<User> spec

Custom operators can be registered as Spring beans. If no bean exists, the library asks Spring to create the operator.

Attribute Paths

The default attribute resolver supports:

  • simple attributes: status
  • nested attributes: organization.region
  • collection joins: labels
  • map keys and values: properties.key, properties.value

Override the default AttributeResolver with your own Spring bean when your project needs aliases, stricter validation, or different join behavior.

Error Handling

The library fails fast for invalid filter definitions and includes the failing condition in the error message.

Examples of reported problems:

  • missing required values
  • invalid JSON body for @Spec.Body
  • failed conversion from request value to entity attribute type
  • invalid attribute path
  • invalid or strict expression alias

License

The library is licensed under the Apache License, Version 2.0.