HTTP QUERY in Java: The Missing Method for Complex REST API Searches
HTTP QUERY gives Java REST APIs a cleaner way to handle complex searches with request bodies, avoiding long URLs and POST misuse while keeping reads explicit.
Join the DZone community and get the full member experience.
Join For FreeHTTP methods in REST API design are more than technical details; they communicate intent between clients and servers. A GET request instructs the server to retrieve a resource. A POST request typically indicates that data should be processed, often creating a new resource. PUT indicates replacement or update, while DELETE signals removal. These methods are well-established and fundamental to the Web.
Despite this, API design has long faced a notable gap.
Challenges arise when a client needs to retrieve data using queries too complex for a URL. Filters such as destination, price range, availability, category, user preferences, pagination, sorting, and business rules can be added as query parameters, but this often results in lengthy, hard-to-read URLs that are difficult to maintain and may not be suitable for sensitive or structured data.
For years, the common workaround was to use POST for search operations:
POST /trips/search
Content-Type: application/json
However, this approach does not align with HTTP semantics. Searching is typically a safe operation that does not alter server state and is often idempotent, producing the same result if the underlying data remains unchanged. POST does not clearly convey this intent, and can complicate caching and retries, and the API documentation is less precise.
The new HTTP QUERY method addresses this specific need. The QUERY method provides a dedicated way to send structured request content while indicating that the operation is safe and idempotent. It functions similarly to GET but allows the client to include a request body, as with POST. According to RFC 10008, a QUERY request asks the target resource to process the enclosed content safely and idempotently, then return the result.
This matters because modern APIs are no longer limited to. This is important because modern APIs often require more than simple resource retrieval. Use cases such as search screens, dashboards, reporting APIs, recommendation engines, GraphQL-like endpoints, analytics filters, and domain-specific query languages demand more expressive input than URLs can provide, yet do not represent state-changing operations. cation protocol, not merely as a transport tunnel. The word “method” in HTTP is important: it defines the request's semantic purpose. QUERY continues that tradition by giving read-oriented complex operations their own explicit place in the protocol.
This article will examine the purpose of QUERY, its differences from GET and POST, appropriate use cases, and its potential to enhance modern Java REST API design.
Building the Sample Project
With the importance of the HTTP QUERY method established, let’s transition from concept to implementation.
This sample uses a travel agency domain. The goal is to expose a search operation that allows clients to filter travel offers by city, travel type, and price range. In such cases, a traditional GET URL can become cumbersome, while using POST is not semantically appropriate.
The project uses Helidon 4.5.0, generated from the official Helidon Starter. Helidon MP provides a MicroProfile/Jakarta-oriented programming model suitable for this example.
After generating the project, configure the dependencies. This sample uses Eclipse JNoSQL 1.2.0-M1 to demonstrate Jakarta NoSQL and Jakarta Data integration.
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.report.sourceEncoding>UTF-8</project.report.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
<jnosql.version>1.2.0-M1</jnosql.version>
</properties>
<dependencies>
<dependency>
<groupId>org.eclipse.jnosql.databases</groupId>
<artifactId>jnosql-oracle-nosql</artifactId>
<version>${jnosql.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jnosql.metamodel</groupId>
<artifactId>mapping-metamodel-processor</artifactId>
<version>${jnosql.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
Two key dependencies are included.
The first dependency enables Eclipse JNoSQL integration with Oracle NoSQL, which supports key-value and document-oriented models. This example uses the document model, as the travel entity maps naturally to a document structure.
The second dependency enables the metamodel processor, which generates the _Travel class. This allows us to use type-safe Jakarta Data restrictions, such as _Travel.city.equalTo(city), for fluent and dynamic queries.
Creating the Domain Entity
With dependencies configured, we can define the domain model.
The Travel entity represents each travel offer, including an identifier, destination city, travel type, and price.
Jakarta NoSQL supports entities as regular classes or Java records. For this small, immutable sample, a Java record is appropriate.
import jakarta.nosql.Column;
import jakarta.nosql.Entity;
import jakarta.nosql.Id;
import java.math.BigDecimal;
import java.util.UUID;
@Entity
public record Travel(
@Id UUID id,
@Column String city,
@Column TravelType type,
@Column BigDecimal price) {
}
These annotations are similar to those in Jakarta Persistence. While the vocabulary differs, the intent remains familiar.
import jakarta.nosql.Column;
import jakarta.nosql.Entity;
import jakarta.nosql.Id;
@Entity marks the record as persistent. @Id specifies the primary identifier. @Column maps fields to the NoSQL database.
Now we define the travel category:
public enum TravelType {
BUSINESS,
LEISURE
}
This approach provides a compact and expressive domain model for the remainder of the article.
Creating the Repository With Jakarta Data
The next step is to create the bridge between Java and the database.
We use Jakarta Data, which offers a repository-oriented programming model compatible with various persistence technologies. In this sample, the repository uses Eclipse JNoSQL and Oracle NoSQL, while the programming model remains domain-focused.
import jakarta.data.repository.BasicRepository;
import jakarta.data.repository.Find;
import jakarta.data.repository.Repository;
import jakarta.data.restrict.Restriction;
import java.util.List;
import java.util.UUID;
@Repository
public interface TravelRepository extends BasicRepository<Travel, UUID> {
@Find
List<Travel> query(Restriction<Travel> restriction);
default boolean isEmpty() {
return countBy() == 0;
}
long countBy();
}
The repository extends BasicRepository<Travel, UUID>, which gives us common persistence operations such as saving entities and retrieving data.
The key method for this article is:
@Find
List<Travel> query(Restriction<Travel> restriction);
A Restriction<Travel> represents a dynamic query condition, which aligns well with the HTTP QUERY scenario. Clients can send various combinations of filters, such as city, price, travel type, or all of them.
Instead of creating separate repository methods for each combination, we build queries dynamically.
The isEmpty() method is a convenience for loading initial data at application startup:
default boolean isEmpty() {
return countBy() == 0;
}
long countBy();
Creating the Filter Request DTO
Next, create the object that represents the request. Here, the HTTP QUERY method is useful. Instead of encoding each filter in the URL, we send a structured request body.
The Java representation is as follows:
import expert.os.demos.travel.infrastructure.FieldVisibilityStrategy;
import jakarta.json.bind.annotation.JsonbVisibility;
import java.math.BigDecimal;
import java.util.Optional;
@JsonbVisibility(value = FieldVisibilityStrategy.class)
public class TravelFilterRequest {
private String city;
private TravelType type;
private BigDecimal minPrice;
private BigDecimal maxPrice;
public Optional<String> city() {
return Optional.ofNullable(city);
}
public Optional<TravelType> type() {
return Optional.ofNullable(type);
}
public Optional<BigDecimal> minPrice() {
return Optional.ofNullable(minPrice);
}
public Optional<BigDecimal> maxPrice() {
return Optional.ofNullable(maxPrice);
}
@Override
public String toString() {
return "TravelRequest{" +
"city='" + city + '\'' +
", type=" + type +
", minPrice=" + minPrice +
", maxPrice=" + maxPrice +
'}';
}
}
A key design choice is that each accessor returns an Optional.
This approach makes the filtering logic explicit. Fields may or may not be present in the request, so the service applies each restriction only when a value exists, avoiding null checks.
The @JsonbVisibility annotation customizes how JSON-B accesses fields. Here, the DTO keeps fields private and exposes domain-friendly accessor methods such as city(), type(), minPrice(), and maxPrice().
Creating the Travel Service
Now, we can create the service layer.
In this sample, the service has two responsibilities:
First, it loads initial travel data if the repository is empty.
Second, it converts incoming filter requests into Jakarta Data restrictions.
import jakarta.annotation.PostConstruct;
import jakarta.data.restrict.Restrict;
import jakarta.data.restrict.Restriction;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.jnosql.mapping.Database;
import org.eclipse.jnosql.mapping.DatabaseType;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.logging.Logger;
@ApplicationScoped
public class TravelService {
private static final Logger LOGGER = Logger.getLogger(TravelService.class.getName());
private final TravelRepository travelRepository;
@Inject
public TravelService(@Database(DatabaseType.DOCUMENT) TravelRepository travelRepository) {
this.travelRepository = travelRepository;
}
TravelService() {
this.travelRepository = null;
}
@PostConstruct
void load() {
if (travelRepository.isEmpty()) {
LOGGER.info("[TRAVEL SERVICE] Loading initial travel data...");
travelRepository.save(new Travel(
UUID.randomUUID(),
"New York",
TravelType.BUSINESS,
new java.math.BigDecimal("1500.00")));
travelRepository.save(new Travel(
UUID.randomUUID(),
"Paris",
TravelType.LEISURE,
new java.math.BigDecimal("2000.00")));
travelRepository.save(new Travel(
UUID.randomUUID(),
"Tokyo",
TravelType.BUSINESS,
new java.math.BigDecimal("3000.00")));
travelRepository.save(new Travel(
UUID.randomUUID(),
"Sydney",
TravelType.LEISURE,
new java.math.BigDecimal("1800.00")));
travelRepository.save(new Travel(
UUID.randomUUID(),
"Rome",
TravelType.LEISURE,
new java.math.BigDecimal("2500.00")));
} else {
LOGGER.info("[TRAVEL SERVICE] Travel data already loaded.");
}
}
public List<Travel> search(TravelFilterRequest filter) {
LOGGER.info("[TRAVEL SERVICE] Searching for travels with filter: " + filter);
if (filter == null) {
return travelRepository.findAll().toList();
}
List<Restriction<Travel>> restrictions = new ArrayList<>();
filter.city()
.ifPresent(city -> restrictions.add(_Travel.city.equalTo(city)));
filter.type()
.ifPresent(type -> restrictions.add(_Travel.type.equalTo(type)));
filter.minPrice()
.ifPresent(minPrice -> restrictions.add(_Travel.price.greaterThanEqual(minPrice)));
filter.maxPrice()
.ifPresent(maxPrice -> restrictions.add(_Travel.price.lessThanEqual(maxPrice)));
return travelRepository.query(
Restrict.all(restrictions.toArray(new Restriction[0])));
}
}
Enabling the HTTP QUERY Method With JAX-RS
The final step is to enable the HTTP QUERY method in the Java API.
JAX-RS simplifies this process. The specification allows custom HTTP method annotations using @HttpMethod. Since QUERY is now an HTTP method, we can create a concise annotation and apply it directly in the resource class.
import jakarta.ws.rs.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@HttpMethod("QUERY")
public @interface QUERY {
}
This annotation functions similarly to the built-in JAX-RS annotations such as @GET, @POST, @PUT, and @DELETE.
The key line is:
@HttpMethod("QUERY")
This informs JAX-RS that methods annotated with @QUERY handle incoming HTTP requests using the QUERY method.
At this stage, the API expresses the operation more clearly. POST is no longer used as a workaround for search. This endpoint now receives a query payload and returns a result without altering server state.
Exposing the Travel Resource
We can now expose the resource.
package expert.os.demos.travel;
import expert.os.demos.travel.infrastructure.QUERY;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.logging.Logger;
@ApplicationScoped
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Path("/travels")
public class TravelResource {
private static final Logger LOGGER = Logger.getLogger(TravelResource.class.getName());
@Inject
private TravelService travelService;
@QUERY
public List<Travel> search(TravelFilterRequest filter) {
LOGGER.info("Searching travels with filter: " + filter);
return travelService.search(filter);
}
}
The resource is intentionally small. Its job is not to understand database details or build dynamic queries. It only receives the HTTP request, delegates the filter to the service, and returns the result.
@QUERY
public List<Travel> search(TravelFilterRequest filter) {
LOGGER.info("Searching travels with filter: " + filter);
return travelService.search(filter);
}
This method is the article's focal point.
Executing the Request
With the service running, test the endpoint using a client that supports custom HTTP methods.
For example, in Postman:
QUERY http://localhost:8081/travels
Content-Type: application/json
{
"type": "BUSINESS",
"maxPrice": 2800
}
Alternatively, in Postman-style command form:
postman request QUERY 'http://localhost:8081/travels' \
--header 'Content-Type: application/json' \
--body '{"type": "BUSINESS", "maxPrice": 2800}'
Given the initial data loaded by the service, this request retrieves business trips with a price of 2800 or less. The matching result should include New York:
[
{
"id": "generated-uuid",
"city": "New York",
"type": "BUSINESS",
"price": 1500.00
}
]
Tokyo is also a business trip, but its price is 3000.00, so it does not match the maxPrice filter.
This example demonstrates the value of QUERY: the client can send a structured request body, the server preserves read-oriented semantics, and the backend maps the request directly into a dynamic Jakarta Data query.
Opinions expressed by DZone contributors are their own.
Comments