Filtering and Pagination
Collection endpoints take query parameters. An optional filter narrows the list. Pagination limits how much is returned. This tutorial shows both, and both together.
A collection read has no branching pipeline, so it stays a single function. This is the read side of the CRUD Orchestration pattern, where the write side is composed of many functions but the list is not.
The domain
An article has a category. The category is what the list is filtered by.
@Entity
@Table(name = "articles")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String category;
}
The repository declares derived finders for the filter, one returning a list and one a page:
@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
// Derived finders for the optional category filter.
List<Article> findByCategory(String category);
Page<Article> findByCategory(String category, Pageable pageable);
}
Sample data is seeded so the endpoints return something:
INSERT INTO articles (title, category) VALUES ('Spring Boot intro', 'spring');
INSERT INTO articles (title, category) VALUES ('Spring Data JPA', 'spring');
INSERT INTO articles (title, category) VALUES ('OfficeFloor orchestration', 'officefloor');
INSERT INTO articles (title, category) VALUES ('Functions as steps', 'officefloor');
INSERT INTO articles (title, category) VALUES ('Testing functions', 'testing');
An optional filter: GET /article
The category query parameter is required = false. When it is present the list is filtered. When it is absent the whole list is returned. The function chooses the query from whether the parameter was given.
service:
class: net.officefloor.tutorial.springrestfilter.ListArticles
govern: [ readonly-transaction ]
/**
* Lists articles, with an optional category filter.
*
* <p>The {@code category} query parameter is {@code required = false}. When it
* is absent the list is unfiltered. A collection read like this has no branching
* pipeline, so it stays a single function.
*/
public class ListArticles {
public void service(
@RequestParam(name = "category", required = false) String category,
ArticleRepository repository,
ObjectResponse<List<ArticleResponse>> response) {
List<Article> articles = (category != null)
? repository.findByCategory(category)
: repository.findAll();
response.send(articles.stream().map(ListArticles::toDto).collect(Collectors.toList()));
}
static ArticleResponse toDto(Article article) {
return new ArticleResponse(article.getId(), article.getTitle(), article.getCategory());
}
}
So GET /article returns everything, and GET /article?category=spring returns only the spring articles. The parameter is optional, so both requests are valid.
Filter plus pagination: GET /article/page
Pagination adds page and size. Both have a defaultValue, so a request needs neither. The optional filter still applies. The filter and the paging combine into one Spring Data query, and the response carries the page metadata clients need.
service:
class: net.officefloor.tutorial.springrestfilter.ListArticlesPage
govern: [ readonly-transaction ]
/**
* Lists articles with the optional category filter and pagination together.
*
* <p>{@code category} is optional. {@code page} and {@code size} have defaults,
* so a request needs neither. The filter and the paging combine into one
* Spring Data query.
*/
public class ListArticlesPage {
public void service(
@RequestParam(name = "category", required = false) String category,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size,
ArticleRepository repository,
ObjectResponse<ArticlePage> response) {
Pageable pageable = PageRequest.of(page, size, Sort.by("id"));
Page<Article> result = (category != null)
? repository.findByCategory(category, pageable)
: repository.findAll(pageable);
response.send(new ArticlePage(
result.getNumber(),
result.getSize(),
result.getTotalElements(),
result.getTotalPages(),
result.getContent().stream().map(ListArticles::toDto).collect(Collectors.toList())));
}
}
The page response is a small DTO of the content plus the totals:
/** A page of articles, with the pagination metadata clients need. */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticlePage {
private int page;
private int size;
private long totalElements;
private int totalPages;
private List<ArticleResponse> content;
}
So GET /article/page uses the defaults, GET /article/page?page=1&size=2 pages through all articles, and GET /article/page?category=officefloor&size=1 pages through one category.
Query parameters in short
Two @RequestParam forms cover collection endpoints. required = false makes a parameter optional, for a filter that may be absent. defaultValue supplies a value when the parameter is omitted, for paging that should always have a page and size. As always, use the name = attribute form.
Testing
The test drives the endpoints with and without the filter, and with and without paging.
@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestFilterTest {
@Autowired
private MockMvc mvc;
// Five articles are seeded (data.sql): 2 spring, 2 officefloor, 1 testing.
// No filter: all articles.
@Test
public void listAll() throws Exception {
mvc.perform(get("/article").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(5)));
}
// Optional filter present: only the matching category.
@Test
public void listFilteredByCategory() throws Exception {
mvc.perform(get("/article?category=spring").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].category").value("spring"));
}
// Pagination without a filter: first page of two.
@Test
public void pageUnfiltered() throws Exception {
mvc.perform(get("/article/page?page=0&size=2").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(2)))
.andExpect(jsonPath("$.totalElements").value(5))
.andExpect(jsonPath("$.totalPages").value(3))
.andExpect(jsonPath("$.page").value(0));
}
// Filter and pagination together.
@Test
public void pageFilteredByCategory() throws Exception {
mvc.perform(get("/article/page?category=officefloor&page=0&size=1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.totalElements").value(2))
.andExpect(jsonPath("$.totalPages").value(2))
.andExpect(jsonPath("$.content[0].category").value("officefloor"));
}
// Defaults apply when page and size are omitted.
@Test
public void pageUsesDefaults() throws Exception {
mvc.perform(get("/article/page").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(5)))
.andExpect(jsonPath("$.size").value(10));
}
}
Next
The Related Entities tutorial adds a second entity and shows shared loads and ownership-scoped loads.

