Skip to content

feat(processor): add @ExposeField filters on a new Operation.SEARCH endpoint - #6

Merged
notablogger merged 25 commits into
mainfrom
feat/expose-field-filters
Oct 26, 2026
Merged

notablogger merged 25 commits into
mainfrom
feat/expose-field-filters

Conversation

@notablogger

@notablogger notablogger commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Summary

This release (4.0.0) adds a search endpoint and field-level control over the generated API:

  • Search: POST /api/<path>/search. List Operation.SEARCH in expose. Clients send a JSON body of filters, for example {"price_gt": 10, "status_in": ["A","B"], "categoryId": 5}, and get back the matching records, paged when pageable = true. It works for JPA and MongoDB, on scalar fields, collections and JPA relations (filtered by id).
  • @ExposeField(filterOperations = {...}) declares the filters a field accepts: EQ, NE, GT, GTE, LT, LTE, LIKE, IN, NOT_IN, IS_NULL. Mistakes are reported at compile time.
  • @ExposeField(hidden = true) keeps a field out of the response and request DTOs.
  • Fix: entities with a generic or array field (List<String>, Set<Integer>, byte[]) now generate DTOs.

⚠️ Breaking changes for 3.x users (major version 4.0.0):

  • ignoredFields is removed from @ExposeEntity / @ExposeDocument. Mark each field instead:
    // 3.x
    @ExposeEntity(path = "users", ignoredFields = {"passwordHash"})
    // 4.0
    @ExposeField(hidden = true) private String passwordHash;
  • The malformed-body 400 (urn:springxpose:malformed-body) now names the failing JSON property: detail says where the body is invalid, and a new errors map is keyed by that property ({"price": "Cannot deserialize value of type …"}). This applies to create, update and search bodies.

The sample is updated in notablogger/spring-xpose-sample-rest#2.

Type

  • 🐛 Bug fix
  • ✨ New feature / annotation attribute
  • ♻️ Refactor (no behaviour change)
  • 🧪 Tests only
  • 📝 Documentation only
  • 🔧 Build / CI change

Related issue

Related: #5 (superseded), #8, #9 (merged into this branch)

What was changed

  • Annotations:

    • New Operation.SEARCH. It's opt-in: it is not one of the default expose operations.
    • New @ExposeField(filterOperations = ..., hidden = ...) and the FilterOperation enum.
    • ignoredFields is removed.
  • The search endpoint:

    • @PostMapping("/search") with @RequestBody(required = false) <Entity>SearchRequest. An empty body ({}) or no body returns every record.
    • With pageable = true, page / size / sort are query parameters and the response is a Page. Without it, the response is a List.
    • It's a read. Security gives POST /api/<path>/search the read rule (readRoles / roles / authenticated() / permitAll()), while POST /api/<path> (create) keeps the write rule.
  • Body keys: EQ uses the field name (price). Every other operation uses field_ plus the lower-cased operation name. Values are JSON: arrays for IN / NOT_IN, booleans for IS_NULL, ISO strings for dates. All keys are optional and combined with AND.

    Operation Key Java type JPA MongoDB
    EQ price field type = is
    NE price_ne field type <> OR IS NULL ne
    GT / GTE / LT / LTE price_gt … field type > >= < <= gt gte lt lte
    LIKE name_like String lower(f) LIKE %v% (escaped) regex(Pattern.quote(v), "i")
    IN "status_in": ["A","B"] List<T> IN (…) in
    NOT_IN "status_not_in": ["A","B"] List<T> NOT IN (…) OR IS NULL nin
    IS_NULL "deletedAt_is_null": true Boolean IS NULL / IS NOT NULL is(null) / ne(null)
  • Unknown keys are rejected with 400, and the error lists the allowed keys. This covers typos, operations a field doesn't declare, and hidden fields. A @JsonAnySetter on the generated class enforces it, so the check holds whatever the application's Jackson setting for unknown properties is.

  • Collection fields (@ElementCollection List<String> tags, Mongo arrays):

    • tags_in means "has any of".
    • tags_not_in means "has none of"; empty collections match.
    • tags_is_null matches null or empty.
    • JPA uses an EXISTS subquery, so rows aren't duplicated and paging totals stay correct.
  • Relation filters:

    Relation Keys Operations JPA
    @ManyToOne, owning @OneToOne categoryId, categoryId_ne, categoryId_in, categoryId_not_in, categoryId_is_null EQ, NE, IN, NOT_IN, IS_NULL root.get("category").get("id"): the FK column, no join
    @OneToMany, @ManyToMany suppliers_in, suppliers_not_in, suppliers_is_null IN, NOT_IN, IS_NULL EXISTS subquery on the related id

    The key's type is the related entity's @Id type, including one inherited from a @MappedSuperclass.

  • hidden = true: leaves the field out of the response and request DTOs. Works on scalar fields, relations and Mongo fields; never on the @Id.

  • Generated code:

    • <Entity>SearchRequest is the JSON body. Each property has @JsonProperty("price_gt") and @Schema(description = …).
    • <Entity>Spec builds a JPA Specification or a MongoDB Query.
    • JPA repositories of searchable entities also extend JpaSpecificationExecutor.
  • OpenAPI:

    • The search body is one typed schema, with an example that lists every key with a type-correct placeholder, so it can be sent as is.
    • Pageable is documented as optional page / size / sort parameters (@ParameterObject when springdoc is present).
  • Error responses: the malformed-body 400 has errors keyed by the JSON path, for example "price_gt", "categoryId_in[1]", or "pirce_gt": "Unknown search key …".

  • Compile-time errors:

    • ranges on non-Comparable types; LIKE on a non-String; IS_NULL on primitives
    • scalar operations on collections; JPA collections without @ElementCollection; raw collections; arrays (except IS_NULL)
    • ranges or LIKE on relations; EQ / NE on to-many relations; the inverse side of a @OneToOne; related entities without a single @Id
    • hidden combined with filterOperations; two filters mapping to the same key
  • Compile-time warnings:

    • filterOperations without SEARCH: the filters are unused.
    • SEARCH without any filterOperations: the endpoint returns every record.
  • Bug fix: DtoGenerator / RequestDtoGenerator emit field types from the TypeMirror, so generic and array fields generate.

  • Tests: 102 processor tests. FilterGeneratorTest covers search generation, security, warnings, the OpenAPI example, and Jackson reading the generated body.

  • Docs:

    • annotation-reference.md has a new Search section. It covers keys, values, 400 errors, collections, relations, compile-time checks, limitations, and why search is a POST, including the plan to also serve it over HTTP QUERY (RFC 10008) once Spring supports it (Spring Framework 7.1, Add RFC 10008 (QUERY HTTP method) support spring-projects/spring-framework#34993).
    • Also updated: generated-artifacts.md, architecture.md, generator-guide.md, the README, and the comparison table.

How to test

  1. Create a JPA entity with expose = {…, Operation.SEARCH} and these fields:
    • @ExposeField(filterOperations = {EQ, NE, LIKE}) String name
    • @ExposeField(filterOperations = {GT, LT}) BigDecimal price
    • @ExposeField(filterOperations = {IN, NOT_IN}) String status
    • @ExposeField(filterOperations = IS_NULL) Instant deletedAt
    • @ElementCollection @ExposeField(filterOperations = {IN, NOT_IN, IS_NULL}) List<String> tags
    • @ManyToOne @ExposeField(filterOperations = {EQ, NE, IN, NOT_IN, IS_NULL}) Category category
    • @ManyToMany @ExposeField(filterOperations = {IN, NOT_IN, IS_NULL}) Set<Supplier> suppliers
    • @ExposeField(hidden = true) String secret
  2. Send POST /api/products/search with these bodies:
    • {"price_gt":10,"price_lt":100}, {"name_like":"widg"}, {"name_like":"50%"} (a literal %)
    • {"status_in":["A","B"]}, {"status_in":[]} (no filter), {"status_ne":"X"} (includes null)
    • {}, and no body at all (both return everything)
  3. Send {"deletedAt_is_null":true}. Each of these should return 400 with errors keyed by the JSON key:
    • {"price_gt":"abc"}, {"deletedAt_is_null":"maybe"}, a non-ISO date
    • {"pirce_gt":1} and {"secret":"x"}; these errors also list the allowed keys
  4. Send {"tags_in":["sale","new"]}, {"tags_not_in":["sale"]} and {"tags_is_null":true}.
  5. Relation filters:
    • {"categoryId":1}: the SQL should be where category_id=?, with no join.
    • {"categoryId_ne":1}: includes uncategorised products.
    • {"suppliers_in":[1,2]} with ?size=2: each product once, correct totalElements.
  6. Check that secret is absent from responses and from OpenAPI.
  7. Security: with readRoles / writeRoles, a read-role user can search but not create.
  8. In /v3/api-docs, /search has an optional body with the ProductSearchRequest schema and an example. Sending that example unchanged returns 200. page / size / sort are optional query params.

All of the above was run end to end against H2 (Spring Boot 4.0.6, springdoc 3.0.3), with this branch published to mavenLocal. MongoDB is covered by compile-tests only.

Generated output (if applicable)

Generated source (excerpt)
public final class ProductSearchRequest {
  @JsonProperty("price_gt")
  @Schema(description = "price greater than")
  private BigDecimal priceGt;

  @JsonProperty("tags_in")
  @Schema(description = "tags has any of")
  private List<String> tagsIn;

  public BigDecimal getPriceGt() { return priceGt; }
  public List<String> getTagsIn() { return tagsIn; }

  @JsonAnySetter
  void rejectUnknownKey(String key, Object value) {
    throw new IllegalArgumentException("Unknown search key '" + key + "' for Product — allowed keys: price_gt, tags_in");
  }
}

@PostMapping("/search")
public ResponseEntity<Page<ProductDto>> search(
    @RequestBody(required = false) ProductSearchRequest request, @ParameterObject Pageable pageable) {
  ProductSearchRequest filters = request != null ? request : new ProductSearchRequest();
  ...
  return ResponseEntity.ok(repository.findAll(ProductSpec.withFilters(filters), pageable).map(mapper::toDto));
}

// ProductSecurityConfigurer
.requestMatchers(HttpMethod.POST, "/api/products/search").hasAnyRole("CUSTOMER", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/products").hasAnyRole("ADMIN")

Checklist

  • All existing tests pass (./gradlew :processor:test): 102/102 locally; ./gradlew build (including javadoc) is clean
  • New tests added / updated to cover the change
  • No MapStruct warnings in the sample project: feat(sample): demonstrate search, hidden fields and paging spring-xpose-sample-rest#2 (52/52 tests against this branch as 4.0.0)
  • README.md updated if a public API / annotation attribute changed
  • docs/tech/ updated if architecture or generated artifacts changed
  • Commit messages follow type(scope): description format

🤖 Generated with Claude Code

https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V

notablogger and others added 5 commits June 12, 2026 07:01
…erations

Filters are now declared per field instead of as a list of names on the
class annotation:

    @ExposeField(operations = {EQ, GT, LT, LIKE})

- New @ExposeField annotation and FilterOperation enum
  (EQ, NE, GT, GTE, LT, LTE, LIKE); filterableFields is removed
- EQ binds ?field=, other operations bind suffixed params (?price_gt=)
  via @BindParam; @parameter(name) keeps the OpenAPI name in sync
- FilterParams is now an immutable class bound through its constructor
- Spec maps each operation for JPA (CriteriaBuilder) and MongoDB
  (Criteria); NE includes nulls on both stores; LIKE is a
  case-insensitive contains with wildcards escaped
- findAll adds springdoc's @ParameterObject when springdoc is on the
  classpath, so each filter is documented as its own query param
- Compile errors for range ops on non-Comparable types, LIKE on
  non-String fields, filters on relations and clashing param names
- Restore comments and Javadoc removed in the earlier filter commits
- Document @ExposeField in README and docs/tech

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
- IN / NOT_IN: ?status_in=A,B (or the param repeated) binds to a
  List of the field type; an empty list adds no condition. NOT_IN
  includes nulls, like NE (JPA: NOT IN ... OR IS NULL; Mongo: $nin)
- IS_NULL: ?deletedAt_null=true / false → IS NULL / IS NOT NULL
  (Mongo: is(null) / ne(null)); compile error on primitive fields
- Multi-word suffixes map to camel-case properties (statusNotIn)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
?deletedAt_is_null=true instead of ?deletedAt_null=true, so every
suffix is the lower-cased operation name. Property: deletedAtIsNull.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
DtoGenerator and RequestDtoGenerator rebuilt each field type from its
text with ClassName.bestGuess, which only handles plain class names.
Any entity with a List<String>, Set<Integer>, Map<..> or array field
failed with "couldn't make a guess for java.util.List<...>".

FieldModel now carries the field's TypeMirror and both generators emit
TypeName.get(type).box().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
On a JPA @ElementCollection or a MongoDB array:
- IN = has any of the values: ?tags_in=sale,new. JPA uses a correlated
  EXISTS subquery (generated <field>HasAny helper) so a row matching
  several values is returned once and paging totals stay correct;
  Mongo uses $in
- NOT_IN = has none of the values, empty collections included
  (NOT EXISTS / $nin)
- IS_NULL = null or empty (isEmpty / tags.0 does not exist)
- The params bind to a List of the element type

EQ, NE, ranges and LIKE are compile errors on collections. JPA
collections must be @ElementCollection; raw collections and array
fields (except IS_NULL) are rejected with a clear message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
The error now says what happens without it: Hibernate stores an
unannotated List<String> in a single array column (or a converter /
JSON stores text), which JPA queries can't search element by element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
…ions

@ExposeField(filterOperations = {EQ, GT}) — the name says what the
operations are for, leaving room for non-filter attributes later.
The Javadoc now also lists the array, raw-collection and name-clash
rules, and warns that ignoredFields can still be filtered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
- annotation-reference: attribute table, operations, value formats
  (ISO dates, lists, booleans), collection fields, every compile-time
  error, limitations (AND only, no relations, LIKE and indexes, case
  sensitivity, unknown params ignored, ignoredFields stay filterable)
  and a workaround for relation filters
- generated-artifacts: file count, property naming, per-operation
  JPA / MongoDB mapping, 400 response keyed by query parameter name
- architecture, generator-guide: the two filter generators and the
  @ExposeField parsing / validation step
- README roadmap and comparison table

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
…rue)

Hiding a field is now declared on the field itself:

    @ExposeField(hidden = true) private String passwordHash;

Hidden fields are left out of the response and request DTOs exactly as
ignoredFields did (scalar fields, JPA relations, Mongo fields; never the
@id). hidden = true combined with filterOperations is a compile error:
a filter would let clients guess the value the API hides.

Internally EntityModel.ignoredFields is renamed hiddenFields.

BREAKING CHANGE: ignoredFields is removed from @ExposeEntity and
@ExposeDocument. Move each listed field to @ExposeField(hidden = true).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
…o feat/expose-field-filters

# Conflicts:
#	processor/src/main/java/io/github/notablogger/springxpose/processor/generator/RestControllerGenerator.java
@ExposeField(filterOperations = ...) on a JPA relation now filters by
the related entity's id instead of failing the build:

- To-one (@manytoone, owning @OnetoOne): ?categoryId=5, categoryId_ne,
  categoryId_in, categoryId_not_in, categoryId_is_null. Compares
  root.get("category").get("id") — the FK column, no join. NE / NOT_IN
  include rows without a related entity.
- To-many (@onetomany, @manytomany): ?suppliers_in=1,2 (has any),
  suppliers_not_in (has none, empty included), suppliers_is_null —
  the same EXISTS subquery as collection fields, comparing the id.
- Param type is the related @id type, found on the entity or a
  @MappedSuperclass.

Compile errors: ranges / LIKE on relations, EQ / NE on to-many, the
inverse side of a @OnetoOne (no FK), related entities without a single
@id (composite ids).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V

Copy link
Copy Markdown
Owner Author

claude-review failure — not caused by this PR.

The check comes from .github/workflows/claude-code-review.yml, added to main by #4 and merged in here. In the run log Claude Code starts, then ends after 1 turn in ~2 s with total_cost_usd: 0 and empty modelUsage, reporting is_error: true — it never reached the model, so it reviewed nothing. That pattern points at authentication: the workflow uses secrets.CLAUDE_CODE_OAUTH_TOKEN, which is most likely missing or invalid (the action hides the exact message; show_full_output: true would show it).

No code change in this PR can fix it; it will fail the same way on every PR until the secret is set under Settings → Secrets and variables → Actions (e.g. via claude setup-token / /install-github-app). After that, a re-run of the job (or the next push) should go through. The repository's own checks (build, processor tests, branch name, PR title) are unaffected.


Generated by Claude Code

Filtering moves from GET query parameters on findAll to a new opt-in
Operation.SEARCH: POST /api/<path>/search with a flat JSON body using the
same keys (price_gt, status_in, categoryId, ...), AND-ed. GET /api/<path>
is an unfiltered list again.

- Operation.SEARCH, not in the default expose list; secured with the
  read roles. page / size / sort stay query parameters when pageable.
- <Entity>FilterParams becomes <Entity>SearchRequest: @JsonProperty keys,
  @Schema descriptions, and a @JsonAnySetter that rejects unknown keys
  (typos, undeclared operations, hidden fields) with 400 whatever the
  FAIL_ON_UNKNOWN_PROPERTIES setting. A missing body is {}.
- Warnings for filterOperations without SEARCH and SEARCH without
  filterOperations.
- SpringXposeExceptionHandler names the failing JSON property in
  "errors" for unreadable bodies (search, create and update).
- Docs: search section, why it is a POST, and the RFC 10008 QUERY plan.

BREAKING CHANGE: GET /api/<path> no longer accepts filter query
parameters; expose Operation.SEARCH and POST the filters as JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
@notablogger notablogger changed the title feat(processor): add @ExposeField filter operations for findAll feat(processor): add @ExposeField filters on a new Operation.SEARCH endpoint Oct 26, 2026
The OpenAPI request body of POST /search gets one example, "example",
with every search key and a placeholder of its type ("string", 0, true,
the first enum constant, ISO dates, one-element arrays for IN / NOT_IN,
true for IS_NULL). Every value is valid for its key, so Swagger UI can
send the example as is; it replaces the "{}" example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
Drop wording that contrasts search with filtering on findAll, which was
never released; the reference section is now "Search — POST
/api/<path>/search", and the OpenAPI example matches the declared fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
@notablogger
notablogger marked this pull request as ready for review October 26, 2026 13:06
@notablogger
notablogger merged commit aa938cf into main Oct 26, 2026
13 checks passed
@notablogger notablogger mentioned this pull request Oct 26, 2026
5 of 12 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants