DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Translating OData Queries to MongoDB in Java With Jamolingo
  • Build a REST API With Just 2 Classes in Java and Quarkus
  • High-Performance Reactive REST API and Reactive DB Connection Using Java Spring Boot WebFlux R2DBC Example
  • Four Essential Tips for Building a Robust REST API in Java

Trending

  • Article Moderation: Your Questions, Answered
  • Engineering High-Performance Real-Time Leaderboard
  • Token Attribution Framework for Agentic AI in CI/CD
  • It’s Not About Control — It’s About Collaboration Between Architecture and Security
  1. DZone
  2. Coding
  3. Java
  4. HTTP QUERY in Java: The Missing Method for Complex REST API Searches

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.

By 
Otavio Santana user avatar
Otavio Santana
DZone Core CORE ·
Jul. 06, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
69 Views

Join the DZone community and get the full member experience.

Join For Free

HTTP 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:

HTTP
 
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.

XML
 
<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.

Java
 
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.

Java
 
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:

Java
 
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.

Java
 
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:

Java
 
@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:

Java
 
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:

Java
 
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.

Java
 
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.

Java
 
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:

Java
 
@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.

Java
 
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.

Java
 
@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:

HTTP
 
QUERY http://localhost:8081/travels
Content-Type: application/json

{
  "type": "BUSINESS",
  "maxPrice": 2800
}


Alternatively, in Postman-style command form:

Shell
 
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:

JSON
 
[
  {
    "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.


API REST Java (programming language)

Opinions expressed by DZone contributors are their own.

Related

  • Translating OData Queries to MongoDB in Java With Jamolingo
  • Build a REST API With Just 2 Classes in Java and Quarkus
  • High-Performance Reactive REST API and Reactive DB Connection Using Java Spring Boot WebFlux R2DBC Example
  • Four Essential Tips for Building a Robust REST API in Java

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook