06 — GET /api/employees: sixty filters, one fields list
Read this first: Part 2 of this course reads one feature from the socket down — the employees grid. It is the same feature Frontend 101 Part 2 reads from the browser: there you watch the grid assemble a URL; here that URL arrives at the backend and you account for every character of it. Three lessons split the vertical. This one is the controller and what each parameter binds to, lesson 07 is the service and its two query paths, and lesson 08 is the Specification layer and the repository underneath. Code is quoted inline so you can read this without the repository open.
Time: about 30 minutes. Assumes lesson 05.
The request, written by hand
This is the kind of request the enterprise grid sends when a user hides most columns, types "cruz" into the last-name header filter, and sets a salary range:
GET /api/employees
?page=0&size=25&sort=lastName,asc
&fields=employeeNumber,lastName,firstName,status,basicSalary
&lastNameSearch=cruz&lastNameSearchType=contains
&salaryMin=30000&salaryMax=60000
&archived=false
Twelve query parameters, and on a busy grid there can be far more. By the end of this lesson you can name the binding destination of every one of them. There are exactly three destinations, and all three are visible in a single method signature.
Three parameters catch the whole query string
The list endpoint, verbatim from
EmployeeController:
/**
* Returns full {@link EmployeeDto}s by default. When {@code fields} is supplied — the enterprise
* grid sends it, derived from its visible columns — the response is instead a page of flat
* maps holding only those columns, so hiding a column narrows the query rather than just the
* rendering.
*
* <p>Filters bind straight into {@link EmployeeListFilter} by component name. {@code page},
* {@code size}, {@code sort} and {@code fields} are not components of that record, so they stay
* with {@link Pageable} and the explicit parameter above.
*/
@GetMapping
@PreAuthorize("hasAuthority('" + PermissionConstants.HR_EMPLOYEES_VIEW + "')")
public ResponseEntity<PageResponseDto<?>> list(
Pageable pageable,
@RequestParam(required = false) String fields,
@ModelAttribute EmployeeListFilter filter) {
if (!StringUtils.hasText(fields)) {
return ResponseEntity.ok(PageResponseDto.of(employeeService.list(pageable, filter)));
}
Set<String> requested = Arrays.stream(fields.split(","))
.map(String::trim)
.filter(field -> !field.isEmpty())
.collect(Collectors.toSet());
return ResponseEntity.ok(PageResponseDto.of(employeeService.listProjected(pageable, filter, requested)));
}
Three parameters, three binding mechanisms:
Pageable pageable— Spring's argument resolver claimspage,sizeandsortbefore anything else sees them.sort=lastName,asccan repeat for multi-column sort.@RequestParam(required = false) String fields— one comma-separated string, absent unless the grid sends it.@ModelAttribute EmployeeListFilter filter— everything else, bound by name into one record.
The javadoc carries the sentence that makes this endpoint worth a lesson:
the response is instead a page of flat maps holding only those columns, so hiding a column narrows the query rather than just the rendering
fields= is not response trimming. The grid derives it from its visible columns, so hiding a
column changes what the database is asked for, not just what the browser paints. The body's
mechanics are deliberately forgiving: split on commas, trim, drop empties, collect to a Set —
fields=a,,b, becomes {a, b}, duplicates collapse, stray commas are harmless. The two branches
call two different service methods, list and listProjected. Those are the two paths in
lesson 07's title.
The second javadoc sentence answers a question you should ask of any @ModelAttribute endpoint:
what keeps page=0 from being mistaken for a filter? Nothing clever — page, size, sort and
fields "are not components of that record, so they stay with Pageable and the explicit
parameter above." @ModelAttribute binds by name across the whole parameter map, so the design
rule is one owner per wire name: the four reserved names are kept out of the record on purpose.
Sixty-four components, bound by name
EmployeeListFilter
is a Java record. Count its components: sixty-four (the lesson title rounds down). Its javadoc is
the grammar for all of them:
/**
* Query-parameter filters for the employees grid, bound by name from the request.
*
* <p>Every grid column that offers a header filter has a matching group here: text columns take a
* {@code xSearch}/{@code xSearchType} pair, numeric and date columns a {@code min}/{@code max} (or
* {@code from}/{@code to}) pair plus a {@code xFilterType} that carries AG Grid's operator
* ({@code equals}, {@code notEqual}, {@code lessThan}, {@code greaterThan}, {@code blank},
* {@code notBlank}; absent means an inclusive range).
*/
Two shapes cover everything. Text columns get a value/operator pair — lastNameSearch plus
lastNameSearchType. Numeric and date columns get a min/max (or from/to) pair plus a
FilterType carrying AG Grid's operator, and when the operator is absent the pair means an
inclusive range — which is why the hand-written request above sends salaryMin and salaryMax
with no salaryFilterType at all. Date components additionally pin the wire format:
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate hireDateFrom,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate hireDateTo,
String hireDateFilterType,
The record is grouped the way the grid is. An unlabeled head covers the original columns — names,
employee number, status, department and position (both as exact departmentId/positionId for
dropdowns and as text search pairs for header filters), salary, hire date, and the archived
flag. Then four comment sections mirror the grid's column groups — // --- personal details ---,
// --- government identifiers ---, // --- compensation detail ---, and
// --- work schedule and separation ---. One section in full, to see the pair pattern repeat:
// --- government identifiers ---
String sssNumberSearch,
String sssNumberSearchType,
String philhealthNumberSearch,
String philhealthNumberSearchType,
String tinNumberSearch,
String tinNumberSearchType,
String pagibigNumberSearch,
String pagibigNumberSearchType,
Predict: the controller has at least two other ways to take these sixty-four values. It could
declare sixty-four @RequestParam method parameters, or keep the record but construct it itself —
new EmployeeListFilter(lastNameSearch, lastNameSearchType, …) with sixty-four positional
arguments. Why does it bind with @ModelAttribute instead? What concretely goes wrong with the
alternatives, and what changes on the wire?
The record's own javadoc resolves it:
with this many components, matching arguments to parameters by hand is a transposition waiting to happen. The wire contract is unchanged — parameter names are the component names.
Look at the government-identifiers section again: eight Strings in a row. A positional
constructor call that swaps tinNumberSearch into the philhealthNumberSearchType slot compiles
without a murmur — same type, wrong meaning — and fails only when someone filters by TIN and gets
nonsense. Sixty-four @RequestParam parameters avoid the transposition but push all sixty-four
names through every layer below. @ModelAttribute matches query-parameter name to component name
mechanically, and the filter travels on as one value — into the service in
lesson 07 and the Specification in
lesson 08. And the wire never sees the difference:
parameter names are the component names either way. If a value cannot bind at all —
salaryMin=abc is not a BigDecimal — binding fails before the method body runs, and the service
never sees a half-typed filter.
The envelope names its fields on purpose
Both branches wrap their page in the same envelope,
PageResponseDto:
public class PageResponseDto<T> {
private List<T> content;
private long totalElements;
private int totalPages;
private int currentPage;
private int pageSize;
public static <T> PageResponseDto<T> of(Page<T> page) {
return new PageResponseDto<>(
page.getContent(),
page.getTotalElements(),
page.getTotalPages(),
page.getNumber(),
page.getSize()
);
}
}
Read the of() factory closely: page.getNumber() lands in currentPage, page.getSize() in
pageSize. Serializing Spring's Page directly would put number and size on the wire, and
that shape is Spring's to change. These five names are the project's list contract instead — the
frontend's pagination footer reads currentPage by exactly that name, so the rename in of() is
a promise, not a style choice. The API conventions page documents the
shape from the client side, notes that currentPage is zero-based, and lists the handful of
endpoints that deviate — deviations you notice precisely because everything else returns this.
The permission is a constant, concatenated
Every endpoint in the controller is annotated the same way:
@PreAuthorize("hasAuthority('" + PermissionConstants.HR_EMPLOYEES_VIEW + "')")
@PreAuthorize takes a SpEL string, and strings do not get compile checking — misspell a
permission inside a literal and the endpoint silently demands an authority nobody holds. The
convention concatenates a compile-time constant into the expression instead:
PermissionConstants.HR_EMPLOYEES_VIEW is "hr.employees.view", defined once in
PermissionConstants.
Misspell the constant name and the build breaks; grep for the constant and you find every
endpoint it gates. Compile-checked-ish: the SpEL around it is still just text, but the part that
varies is the part the compiler sees. The rest of the controller reads as a permission map —
HR_EMPLOYEES_VIEW on the reads, HR_EMPLOYEES_CREATE and HR_EMPLOYEES_EDIT on the writes, and
the delete-level permission gating status, archive and restore.
Where the endpoint lands in the API reference
GET /api/employees appears in the published reference because
OpenApiConfiguration
says so. That class declares nine GroupedOpenApi beans, one per section of the reference, and
/api/employees/** is the first prefix of the hr group — which is why this endpoint's published
home is the HR section. The class javadoc states the rule that keeps the reference
honest:
* <p><b>Every endpoint must land in exactly one group.</b> A path matched by no
* group is absent from the published reference without anything failing, which
* is why CI cross-checks the union of the groups against the ungrouped document
* and fails on any orphan — see the {@code spec} job in
* .github/workflows/docs.yml and docs-site/scripts/fetch-openapi.mjs. If you add
* a controller with a new prefix, add the prefix here and to the group list in
* those two places.
That is a three-file sync hazard, named in the source itself: the group prefixes in
OpenApiConfiguration, the group list in
docs-site/scripts/fetch-openapi.mjs, and the spec
job in .github/workflows/docs.yml must agree, and the CI
cross-check exists because "absent without anything failing" is the worst kind of drift. One more
javadoc sentence to keep you from hunting for Swagger on production:
* <p><b>Availability.</b> springdoc is gated on {@code SWAGGER_ENABLED}
* (application.yml), which is true in dev and deliberately false in stage and
* production — the spec is generated from a dev-configured boot in CI, never
* scraped from a live deployment.
Where this shows up in MotorPH
EmployeeController.java— the endpoint, the fields split, and the permission map.EmployeeListFilter.java— the sixty-four components and the binding javadoc.PageResponseDto.java— the envelope every list endpoint returns.PermissionConstants.java— every permission string, defined once.OpenApiConfiguration.java,fetch-openapi.mjsanddocs.yml— the three files that must agree on the groups.- Frontend 101, lesson 07 — the client half of
fields=: column visibility drives the projection. - Frontend 101, lesson 11 — this same request, read from the browser instead of the socket.
- API conventions — the wire contract as a client sees it; the HR reference — where this endpoint is published.
Recap
- Three parameters catch the whole query string.
page/size/sortgo toPageable,fieldsto one@RequestParam, everything else to@ModelAttribute EmployeeListFilter— one owner per wire name, and the four reserved names are kept out of the record on purpose. fields=narrows the query, not the rendering. Nofieldsmeans fullEmployeeDtos; with it, the response is flat maps of only the visible columns, and hiding a column shrinks the database work.- Sixty-four components bind by name. Text columns are
xSearch/xSearchTypepairs, numeric and date columns aremin/maxplus an operator; positional construction is "a transposition waiting to happen", and the wire contract is the component names either way. currentPageis a promise, not Spring'snumber.PageResponseDto.of()renames deliberately, and the frontend depends on the five names it produces.- Every endpoint lands in exactly one OpenAPI group, three files must agree on the prefixes, and CI fails on any orphan.