JAX-RS: Building REST APIs
In Phase 3 you got the container to build and wire your objects: a
ProductService that holds the actual logic, handed to whatever needs it via @Inject. So far that
service has been reachable only from other Java code. This phase opens a door to it from the outside
world — an HTTP request from a browser, a mobile app, or another service can now reach in and ask for a
product.
The mental model to hold onto: a JAX-RS resource is the translator between HTTP and your method calls.
A request arrives as raw bytes — a URL, a method like GET, maybe a JSON body. Something has to read
that, figure out what's being asked, call the right Java method, and turn the answer back into an HTTP
response. That "something" is a resource class. You don't write the part that parses HTTP or formats the
reply — the application server does that. You write small methods and label them so the server knows
"when GET /api/products comes in, call this one." Same inversion of control you saw with CDI: you don't
call the framework, the framework calls you.
📝 JAX-RS (Jakarta RESTful Web Services) is the standard REST spec — the official, vendor-neutral way
to build HTTP APIs in Jakarta EE. If you've seen Spring's @RestController
(Spring Boot From Zero), JAX-RS is the standards-body equivalent: same
job, different annotations, and your app server (WildFly, Payara, Open Liberty) ships the engine instead
of one vendor's framework. If words like HTTP method, status code, or resource feel fuzzy, the
REST APIs Explained and
HTTP & JSON API Basics guides cover the protocol side; here we focus
on the Jakarta side.
The running example is the Product from Phase 3 — four fields, a plain Java object:
What just happened: That's the thing we'll move across the wire — an id, a name, a price, and a
sku (the stock-keeping unit, a product's unique catalog code). Nothing Jakarta-specific about it yet;
it's an ordinary object, the same one ProductService already manages.
The Application class and your first resource
Two pieces bootstrap a JAX-RS API. First, an Application class turns the feature on and picks the
base path. You write it once, and it's usually empty:
What just happened: @ApplicationPath("/api") told the server "activate JAX-RS, and every endpoint
lives under /api." Extending Application with no body is the standard "scan for my resources
automatically" signal — the server finds your resource classes for you. You don't register anything by
hand; the annotation plus the empty subclass is the whole setup.
📝 Second, a resource class is where endpoints actually live. You mark it with @Path to give it a
URL, and its methods become the handlers. Crucially, a JAX-RS resource is also a CDI bean — so the
@Inject you learned in Phase 3 works right here. That's how the resource gets hold of ProductService.
What just happened: @Path("/products") mapped this class to the URL /api/products — the
/api from the Application class, then /products from here. @Inject ProductService service is pure
CDI: the container hands this resource a fully-wired ProductService, the same way it would any other
bean. This is the Jakarta parallel to Spring's @RestController with a constructor-injected service —
the resource speaks HTTP, the service holds the logic.
HTTP method annotations
The class has a URL; now its methods need to say which HTTP verb they answer and what format they
speak. Each handler gets a method annotation (@GET, @POST, @PUT, @DELETE) plus content-type
annotations.
📝 @GET/@POST/@PUT/@DELETE map a method to that HTTP method. @Produces declares the format the
method sends back; @Consumes declares the format it accepts. For a JSON API both are
MediaType.APPLICATION_JSON. Here's a @GET that returns the whole list of products:
What just happened: @GET said "handle GET requests to this resource's path." @Produces(APPLICATION_JSON)
said "my response is JSON." The method returns a List<Product> — and because the response type is JSON,
the server hands that list to JSON-B (more on it shortly), which turns each Product into a JSON
object automatically. You wrote no serialization code; you delegated the work to service.findAll()
and the formatting to JSON-B.
A request and the response it produces:
GET /api/products HTTP/1.1
Host: localhost:8080
What just happened: The List<Product> came back as a JSON array, one object per product, each field
mapped by name. That field-by-field translation is JSON-B doing its job — your method just returned plain
Java objects and let the spec handle the wire format.
💡 The HTTP verb carries the meaning, so you don't put it in the URL. GET /api/products reads;
POST /api/products creates; DELETE /api/products/2 removes. Same noun (products), different
verbs — that's the REST convention from REST APIs Explained, and JAX-RS
leans on it directly.
Path and query params
A real API needs to address one specific product and to filter a list. Those are two different jobs, and JAX-RS has a different tool for each.
📝 A path param is part of the URL path itself — /api/products/2 means "the product whose id is 2."
You write a placeholder in @Path with braces (@Path("/{id}")) and bind it with @PathParam. A
query param is a value after the ? — /api/products?maxPrice=50 — and you bind it with
@QueryParam. The rule of thumb: a path param identifies a resource; a query param filters or
modifies a request.
What just happened: In getProduct, the {id} in @Path("/{id}") lines up with @PathParam("id") Long id —
the server pulls 2 out of /api/products/2, converts the text to a Long for you, and passes it in.
In listProducts, @QueryParam("maxPrice") reads the ?maxPrice=... query string; when the client
omits it the param is null, so we return everything. One noun, two behaviors, driven by the URL.
Try both with curl:
{"id":2,"name":"USB-C Hub","price":49.50,"sku":"HUB-USBC-07"}
[{"id":2,"name":"USB-C Hub","price":49.50,"sku":"HUB-USBC-07"}]
What just happened: The first call hit the path-param route and returned a single product. The second
hit the same /api/products endpoint but, because ?maxPrice= was present, returned a filtered array
(only the hub came in under 60). The quotes around the second URL keep the shell from choking on the ?.
Request bodies and the Response object
Reading is half an API. To create a product the client sends data in the request body, and to answer honestly you sometimes need to control the status code yourself.
📝 JSON-B (Jakarta JSON Binding) is the standard object↔JSON mapper — the spec's built-in translator,
the role Jackson plays in Spring. It serializes your return values to JSON (the @GETs above) and
deserializes incoming JSON into Java objects. So a @POST method that takes a Product parameter gets a
fully-populated object: JSON-B read the body and filled the fields before your code ran.
That covers the body. For the status code, returning a bare object always yields 200 OK, which
isn't always the truth. Creating a resource should report 201 Created; a missing product should
report 404 Not Found. To say exactly which status (and which headers), you return a Response
instead of the plain object.
What just happened: createProduct takes a Product parameter with no annotation — JAX-RS treats the
unannotated parameter as the request body, and @Consumes(APPLICATION_JSON) tells JSON-B to
deserialize the incoming JSON into it. We hand it to service.create(...), then build a Response:
Response.created(location) sets status 201 and a Location header pointing at the new product's
URL, and .entity(saved) attaches the body. In getProduct, a missing product returns a clean 404
with no body, while a hit returns 200 with the product. Contrast that with the earlier methods that
returned a bare object and always got 200 — Response is the switch you flip when the default status
isn't the honest answer.
The request the client sends:
What just happened: The client posts a product with no id — the server assigns that on create. JSON-B
binds name, price, and sku onto a Product before createProduct runs, so by the time your code
executes you're holding a real, populated Java object, not raw text.
How it all fits
It's worth stepping back to see the layers, because it's the same shape you'll repeat for every resource.
flowchart TD
A[HTTP request to /api/products] --> B[JAX-RS matches @Path + verb]
B --> C[Bind path/query params and JSON-B body]
C --> D[Resource method calls injected ProductService]
D --> E[Service holds the logic - JPA next, in Phase 5]
E --> F[JSON-B serializes the result]
F --> G[Response with status + body sent back]
💡 Notice how thin the resource is. The best JAX-RS methods do three things: read the request, hand off
to the injected ProductService for the real work, and shape the response. The service is where logic
lives — and in Phase 5 that service will swap its in-memory placeholder for
Jakarta Persistence (JPA), reading and writing real database rows. The resource won't change at all;
that's the payoff of keeping the layers separate.
⚠️ Keep persistence and business logic out of the resource. It's tempting to drop a database query or a pricing rule straight into a handler because it "works." It does — until that logic needs testing (you'd have to fake an HTTP request to exercise it), reusing (a scheduled job can't send itself a request), or changing (and now every change touches the doorway). A resource that does two jobs becomes the place every bug hides. HTTP in, HTTP out; delegate the rest to the CDI bean.
📝 One more thing worth knowing: JAX-RS isn't only for serving requests. It also has a Client API
(ClientBuilder.newClient()) for calling other services' REST endpoints from your own code — handy
when one service needs to talk to another. We won't use it here, but it's the same spec, the other
direction.
Recap
- JAX-RS is the standard REST spec. An
@ApplicationPathApplicationsubclass switches it on and sets the base path;@Pathresource classes hold your endpoints — the standards-body counterpart to Spring's@RestController. - Resources are CDI beans.
@Injectworks inside a resource, so it gets a fully-wiredProductServicefor free — the resource stays a thin translator while the service owns the logic. - Method annotations route by verb and format.
@GET/@POST/@PUT/@DELETEpick the HTTP method;@Producesand@Consumesdeclare JSON in and out. - Path params identify, query params filter.
@PathParambinds{id}from the path (one specific product);@QueryParambinds?maxPrice=...(filter or modify), and isnullwhen omitted. - JSON-B maps objects to and from JSON. It serializes return values and deserializes the unannotated
body parameter on a
@POST— the standard's answer to Jackson. Responsecontrols status and headers. Return a bare object for the default 200, or aResponsefor 201 Created (with aLocationheader), 404 Not Found, and anything else where the status is part of the answer. ⚠️ Real persistence lives in the service, arriving next in Phase 5.
Quick check
Make sure the core JAX-RS ideas stuck:
[
{
"q": "What does the @ApplicationPath(\"/api\") class do in a JAX-RS app?",
"choices": [
"Bootstraps JAX-RS and sets the base path that all resources live under, so /products becomes /api/products",
"Connects the application to the database automatically",
"Defines a single endpoint at /api that returns all resources",
"Replaces the need for @Path on resource classes"
],
"answer": 0,
"explain": "An Application subclass annotated with @ApplicationPath activates JAX-RS and declares the base path. Each resource's @Path is then appended to it — @Path(\"/products\") under @ApplicationPath(\"/api\") serves /api/products. It does nothing with the database, and resources still need their own @Path."
},
{
"q": "You want to fetch one specific product by its id from /api/products/2. Which annotation binds that 2 to your method parameter?",
"choices": [
"@PathParam, because the id is part of the URL path and identifies a specific resource",
"@QueryParam, because all URL values use the same annotation",
"An unannotated parameter, because the id travels in the request body",
"@GET, because that annotation reads the value for you"
],
"answer": 0,
"explain": "A value embedded in the path (matched by @Path(\"/{id}\")) is bound with @PathParam — it identifies a resource. @QueryParam is for values after the ? (like ?maxPrice=...), which filter or modify. The unannotated parameter is the JSON body, handled by JSON-B."
},
{
"q": "Your create endpoint returns a plain Product object, and clients always get HTTP 200 even though a resource was created. How do you report 201 Created instead?",
"choices": [
"Return a Response, e.g. Response.created(location).entity(saved).build(), which carries the status and a Location header alongside the body",
"Add @POST(status = 201) to the method",
"Throw an exception after saving so the server picks a different code",
"Nothing can change it — JAX-RS methods can only return 200"
],
"answer": 0,
"explain": "Returning a bare object gives the default 200. To control the status (and headers), return a Response — Response.created(location) reports 201 Created and sets the Location header to the new resource's URL. The same object gives you 404 via Response.status(Response.Status.NOT_FOUND).build()."
}
]
← Phase 3: CDI: Contexts & Dependency Injection · Guide overview · Phase 5: Jakarta Persistence (JPA) →
Check your understanding
1. What does the @ApplicationPath("/api") class do in a JAX-RS app?
2. You want to fetch one specific product by its id from /api/products/2. Which annotation binds that 2 to your method parameter?
3. Your create endpoint returns a plain Product object, and clients always get HTTP 200 even though a resource was created. How do you report 201 Created instead?