What You Will Build
A complete Jakarta EE RESTful web service that performs CRUD operations on a Product entity, backed by a MySQL database and deployed on GlassFish 7.
Unlike the Faces-based labs of Weeks 9โ10, this week there is no web UI. You will build a headless API that receives and returns JSON. Clients (browsers, mobile apps, frontend frameworks) consume the API over HTTP.
LO 1 JPA Entity & Persistence
- Create a
Productentity mapped to a MySQL table - Configure
persistence.xmlwith JTA and auto-schema generation
LO 2 JAX-RS Application Config
- Register the JAX-RS servlet with
@ApplicationPath - Understand the URL formula: context root + app path + resource path
LO 3 CRUD Resource Endpoints
@GET,@POST,@PUT,@DELETEmethods@PathParamfor resource identificationResponseclass for status codes (200, 201, 204, 404)
LO 4 Testing with curl
- Send JSON payloads from the command line
- Verify every CRUD operation end-to-end
- Read HTTP status codes to confirm correct behaviour
- MySQL is running and accessible
- GlassFish 7 is running โ
http://localhost:4848loads the Admin Console - A JDBC Connection Pool and JDBC Resource are configured in GlassFish (from earlier labs)
- You understand JPA entities and
persistence.xmlfrom Weeks 1โ4
RESTful services use a different architecture from Faces-based apps. Start with a brand-new Maven Web Application.
Create the Maven Web Application
Set up a new project in NetBeans, configure the Maven dependencies, and review the file structure you will build.
Create the Project
In NetBeans, go to:
Select Categories: Java with Maven โ Projects: Web Application. Click Next.
| Field | Value |
|---|---|
| Project Name | ProductRestService |
| Group Id | au.edu.cqu |
| Package | au.edu.cqu.productrest |
Click Next.
| Field | Value |
|---|---|
| Server | GlassFish Server |
| Java EE Version | Jakarta EE 10 Web (or latest available) |
Click Finish. NetBeans generates the project structure.
Add it first:
Point it to your GlassFish installation directory.
Configure pom.xml
Open pom.xml from the Project Files node. Ensure these dependencies are present inside <dependencies>:
<!-- Jakarta EE API โ GlassFish provides these at runtime -->
<dependency>
<groupId>jakarta.platform</groupId>
<artifactId>jakarta.jakartaee-api</artifactId>
<version>10.0.0</version>
<scope>provided</scope>
</dependency>
<!-- MySQL JDBC driver -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
GlassFish already contains the Jakarta EE libraries (JAX-RS, JPA, CDI, JSON-B, etc.). The provided scope means "compile against this, but the server supplies it at runtime." This keeps your WAR file small. The MySQL connector does not use provided because GlassFish does not bundle it.
Right-click the project โ
Watch the Output window โ Maven downloads any missing dependencies.Files You Will Create in This Lab
java/au/edu/cqu/productrest/
Product.java
RestApplication.java
ProductResource.java
resources/META-INF/
persistence.xml
webapp/
WEB-INF/ (generated by NetBeans)
A RESTful web service has no user interface. No Facelets pages, no h:form, no managed beans. The only "views" are JSON responses returned over HTTP. Clients like curl, Postman, or a JavaScript frontend consume the API.
Create the Product Entity and persistence.xml
Define a JPA entity that maps to a MySQL table. JPA auto-creates the table on first deployment โ no manual SQL required.
Create Product.java
Right-click the package au.edu.cqu.productrest โ
Class Name: Product. Click Finish.
Delete everything NetBeans generated and paste the code below:
package au.edu.cqu.productrest;
import jakarta.persistence.*;
import java.io.Serializable;
/**
* JPA ENTITY โ each instance represents one row in the "products" table.
*
* @Entity tells JPA "this is a database entity"
* @Table(name=...) maps it to the named table
* @Id marks the primary key
* @GeneratedValue MySQL auto-increments the ID
*
* JSON-B (bundled with GlassFish) automatically converts this object
* to/from JSON using the getter methods โ no extra annotations needed.
*/
@Entity
@Table(name = "products")
public class Product implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private double price;
// Default constructor โ required by JPA, never remove this
public Product() {}
// Getters and Setters โ JSON-B uses these for serialisation
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String d) { this.description = d; }
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }
}
GlassFish bundles Jakarta JSON Binding (JSON-B), which automatically converts Java objects to/from JSON using your getter methods. getName() becomes "name" in JSON. No @JsonProperty or manual parsing needed.
Create persistence.xml
Right-click src/main/resources โ
Name it META-INF.
Right-click META-INF โ
Name it persistence. Click Finish. Replace the entire file with:
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="3.0"
xmlns="https://jakarta.ee/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd">
<persistence-unit name="ProductPU"
transaction-type="JTA">
<!-- JNDI name MUST match your GlassFish JDBC Resource -->
<jta-data-source>jdbc/myDatasource</jta-data-source>
<class>au.edu.cqu.productrest.Product</class>
<properties>
<!-- Auto-create tables on first deployment -->
<property name="jakarta.persistence.schema-generation.database.action"
value="create"/>
</properties>
</persistence-unit>
</persistence>
The <jta-data-source> value must exactly match the JDBC Resource name in GlassFish Admin Console:
- Open
http://localhost:4848 - Navigate to Resources โ JDBC โ JDBC Resources
- Copy the JNDI name (e.g.
jdbc/myDatasource) and paste it into persistence.xml
If it doesn't match, JPA will fail with a "data source not found" error at deployment.
"create" tells JPA to auto-create the products table on first deployment. After the table exists, change this to "none" to prevent data loss on redeployment. Use "drop-and-create" during development if you want a fresh table every time.
Register the REST Servlet with GlassFish
A single small Java class tells GlassFish "this project has REST endpoints" and defines the base URL path for all resources.
Create RestApplication.java
Right-click the package au.edu.cqu.productrest โ
Class Name: RestApplication. Click Finish.
package au.edu.cqu.productrest;
import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;
/**
* JAX-RS APPLICATION CONFIG
*
* This class does two things with one annotation:
* 1. Registers the JAX-RS servlet with GlassFish
* 2. Sets "/api" as the base path for ALL REST endpoints
*
* After this, a resource at @Path("/products") becomes accessible at:
* http://localhost:8080/ProductRestService/api/products
* |__ context root __|__ app path __|__ resource __|
*
* No code needed inside โ the annotation does all the work.
*/
@ApplicationPath("/api")
public class RestApplication extends Application {
// intentionally empty
}
| Part | Source | Example Value |
|---|---|---|
| Context Root | Project name (set in GlassFish) | /ProductRestService |
| Application Path | @ApplicationPath("/api") | /api |
| Resource Path | @Path("/products") | /products |
Full URL: http://localhost:8080/ProductRestService/api/products
Create the ProductResource with Read Operations
Build the REST resource class with two GET endpoints: list all products, and retrieve one by ID. Deploy and verify before moving on.
Create ProductResource.java
Right-click the package โ
Class Name: ProductResource. Replace contents with:
package au.edu.cqu.productrest;
import jakarta.persistence.*;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import java.util.List;
/**
* REST RESOURCE โ the equivalent of a Managed Bean in Faces, but instead
* of rendering XHTML pages it returns JSON over HTTP.
*
* @Path("/products") maps this class to /api/products
* @Produces(JSON) all methods return JSON by default
* @Consumes(JSON) all methods accept JSON input by default
* @PersistenceContext injects the JPA EntityManager
*/
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ProductResource {
@PersistenceContext(unitName = "ProductPU")
private EntityManager em;
// โโ GET /api/products โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Returns ALL products as a JSON array.
// Empty table โ returns []. HTTP status: 200 OK (default).
@GET
public List<Product> getAll() {
return em.createQuery("SELECT p FROM Product p",
Product.class).getResultList();
}
// โโ GET /api/products/{id} โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Returns ONE product by primary key.
// @PathParam extracts {id} from the URL.
// Returns 404 if the ID does not exist.
@GET
@Path("/{id}")
public Response getById(@PathParam("id") Long id) {
Product p = em.find(Product.class, id);
if (p == null) {
return Response.status(404).build();
}
return Response.ok(p).build();
}
}
Returning Product directly always sends HTTP 200. The Response class lets you set the exact status code (200, 201, 204, 404). For getAll(), returning the list directly is fine because even an empty list is a valid 200. For getById(), you need to distinguish "found" (200) from "not found" (404).
Deploy and Test GET
- Right-click the project โ
- Open GlassFish Admin Console โ Applications โ Undeploy any old version
- Right-click the project โ
- Watch the GlassFish Output tab for errors
Open: http://localhost:8080/ProductRestService/api/products
You should see an empty JSON array: []
This is correct โ the table was just created and has no data yet.
- Browser shows
[](empty JSON array) โ not a 404 or error page - GlassFish Output shows "ProductRestService was successfully deployed"
- No exceptions in the GlassFish server log
- In MySQL, a
productstable now exists
Check these three things in order:
RestApplication.javaexists and has@ApplicationPath("/api")ProductResource.javahas@Path("/products")at the class level- The context root is correct โ check GlassFish Admin Console โ Applications
Add the Ability to Create Products
Add a POST method that accepts a JSON product in the request body, persists it to the database, and returns 201 Created.
Add the POST Method
Open ProductResource.java. Add the following method below the getById method:
// โโ POST /api/products โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Creates a new product from the JSON request body.
//
// JSON-B converts the incoming JSON into a Product object automatically.
// Example request body:
// {"name":"Laptop","description":"A fast laptop","price":999.99}
//
// @Transactional wraps the method in a JTA transaction.
// Without this, em.persist() throws an error because there is
// no active transaction.
//
// Returns 201 Created with the saved product (including generated ID).
@POST
@Transactional
public Response create(Product product) {
em.persist(product);
return Response
.status(Response.Status.CREATED) // 201
.entity(product) // includes the generated ID
.build();
}
Add this import at the top of the file if it's not already there:
import jakarta.transaction.Transactional;
Without @Transactional, em.persist() fails with a 500 error because there is no active JTA transaction wrapping the database write.
Redeploy and Test with curl
Right-click the project โ Clean and Build โ Deploy.
Open a command prompt (Windows) or terminal (Mac/Linux) and run:
# Create the first product
curl -X POST http://localhost:8080/ProductRestService/api/products ^
-H "Content-Type: application/json" ^
-d "{\"name\":\"Laptop\",\"description\":\"A fast laptop\",\"price\":999.99}"
# Create a second product
curl -X POST http://localhost:8080/ProductRestService/api/products ^
-H "Content-Type: application/json" ^
-d "{\"name\":\"Wireless Mouse\",\"description\":\"Ergonomic mouse\",\"price\":35.99}"
# Verify both were saved
curl http://localhost:8080/ProductRestService/api/products
Windows Command Prompt: Use ^ for line continuation and escape double quotes with \" inside the -d string (as shown above).
Mac/Linux Terminal: Use \ for line continuation and single quotes around the JSON body:
curl -X POST ... -d '{"name":"Laptop","description":"A fast laptop","price":999.99}'
- The POST returns the product with an auto-generated
idfield - The final GET returns a JSON array containing both products
Complete the CRUD Operations
Add update and delete methods, then run an end-to-end test of all four CRUD operations.
Add the PUT Method (Update)
Add below the create method:
// โโ PUT /api/products/{id} โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Updates an existing product. ID from URL, new values from JSON body.
// Pattern: find โ null check โ copy fields โ merge โ respond.
// Returns 200 OK with updated product, or 404 if not found.
@PUT
@Path("/{id}")
@Transactional
public Response update(@PathParam("id") Long id,
Product updated) {
Product existing = em.find(Product.class, id);
if (existing == null) {
return Response.status(404).build();
}
existing.setName(updated.getName());
existing.setDescription(updated.getDescription());
existing.setPrice(updated.getPrice());
em.merge(existing);
return Response.ok(existing).build(); // 200 OK
}
Add the DELETE Method
Add below the update method:
// โโ DELETE /api/products/{id} โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Removes a product by ID.
// Returns 204 No Content on success (no response body).
// Returns 404 if the ID does not exist.
@DELETE
@Path("/{id}")
@Transactional
public Response delete(@PathParam("id") Long id) {
Product p = em.find(Product.class, id);
if (p == null) {
return Response.status(404).build();
}
em.remove(p);
return Response.noContent().build(); // 204 No Content
}
Full CRUD Test Walkthrough
Right-click โ Clean and Build โ Deploy. Run these commands in order and verify each response:
| # | Operation | curl Command | Status | What to Check |
|---|---|---|---|---|
| 1 | List all | curl .../api/products | 200 | Returns [] or existing products |
| 2 | Create | curl -X POST ... -d '{"name":"Phone","price":699}' | 201 | Returns product with auto-generated id |
| 3 | Read one | curl .../api/products/1 | 200 | Returns the Phone product |
| 4 | Update | curl -X PUT .../1 -d '{"name":"Smartphone","price":749}' | 200 | Returns updated product |
| 5 | Delete | curl -X DELETE .../api/products/1 | 204 | No body returned |
| 6 | Verify | curl .../api/products/1 | 404 | Product no longer exists |
Add -w "\n%{http_code}\n" to any curl command to print the status code after the response body.
Your RESTful CRUD service is fully working. Take a screenshot of your terminal output โ useful evidence for your portfolio.
End-to-End Testing Checklist
Every item must pass. Work through them in order.
Project Structure
- persistence.xml is inside
src/main/resources/META-INF/ <jta-data-source>matches the JDBC Resource in GlassFishRestApplication.javaextendsApplicationwith@ApplicationPath("/api")ProductResource.javahas@Path("/products")
GET Endpoints
GET /api/productsโ 200 with JSON arrayGET /api/products/1โ 200 with single JSON objectGET /api/products/9999โ 404
POST Endpoint
POSTwith valid JSON โ 201 Created with auto-generatedid- POST without
Content-Type: application/jsonโ 415 - Product appears in the MySQL
productstable
PUT Endpoint
PUT /api/products/1with new data โ 200 with updated productPUT /api/products/9999โ 404- Subsequent GET returns the updated values
DELETE Endpoint
DELETE /api/products/1โ 204 No ContentDELETE /api/products/9999โ 404- After DELETE,
GET /api/products/1โ 404
Final File Structure
java/au/edu/cqu/productrest/
Product.java โ (JPA entity)
RestApplication.java โ (JAX-RS config)
ProductResource.java โ (CRUD endpoints)
resources/META-INF/
persistence.xml โ
Common Errors and How to Fix Them
| Error / Symptom | Most Likely Cause | Fix |
|---|---|---|
| 404 Not Found on all /api/ URLs | RestApplication.java is missing, doesn't extend Application, or @ApplicationPath is misspelled |
Verify the class exists, extends jakarta.ws.rs.core.Application, and has @ApplicationPath("/api"). Check the context root in GlassFish Admin Console โ Applications. |
| 500 Internal Server Error on deployment | Persistence unit not found โ persistence.xml is in the wrong directory or JNDI name doesn't match |
Confirm persistence.xml is in src/main/resources/META-INF/ (not webapp/META-INF). Verify <jta-data-source> matches GlassFish. Ping the JDBC pool in Admin Console. |
| POST returns 415 Unsupported Media Type | Missing Content-Type header in curl |
Add -H "Content-Type: application/json" to your curl POST/PUT commands. |
| POST returns 500 โ "no transaction is currently active" | Missing @Transactional |
Add @Transactional to POST, PUT, DELETE methods. Import jakarta.transaction.Transactional. |
JSON response shows empty {} |
Entity fields have no getters | Ensure every field has a public getXxx() method. JSON-B ignores fields without getters. |
| Table not created in MySQL | schema-generation property missing or set to "none" |
Set database.action to "create" in persistence.xml. Ensure the MySQL user has CREATE TABLE permission. |
| Deployment error: "not annotated with @Entity" | Wrong import โ javax.persistence.Entity instead of jakarta.persistence.Entity |
Replace all javax.persistence.* with jakarta.persistence.*. GlassFish 7 uses the Jakarta namespace. |
| Browser shows XML instead of JSON | Browser sends Accept: text/html by default |
This is cosmetic. Use a JSON formatter extension (e.g. JSONView) or test exclusively with curl. |
| PUT makes fields null | JSON body is missing some fields โ JSON-B sets them to defaults | Always include ALL fields in PUT request body. PUT replaces the entire resource. |
| Data reappears after redeploy | Schema generation set to "drop-and-create" |
Expected with "drop-and-create". Change to "none" for persistence. |
Going Further
Extension 1 โ Search with @QueryParam
Add filtering to GET /api/products. If the caller provides ?name=Laptop, return only products whose name contains that string (case-insensitive). If no parameter is provided, return all products.
@GET
public List<Product> getAll(@QueryParam("name") String name) {
if (name != null && !name.isBlank()) {
return em.createQuery(
"SELECT p FROM Product p WHERE LOWER(p.name) LIKE :n",
Product.class)
.setParameter("n", "%" + name.toLowerCase() + "%")
.getResultList();
}
return em.createQuery("SELECT p FROM Product p",
Product.class).getResultList();
}
Test: curl .../api/products?name=lap should return only "Laptop".
Extension 2 โ Add a Category Entity with @ManyToOne
Create a Category entity with id and name. Add a @ManyToOne relationship from Product to Category. Build a CategoryResource at @Path("/categories") with GET and POST endpoints.
Workflow: Create a category first, then create a product referencing the category ID.
Extension 3 โ Input Validation with Bean Validation
Add Jakarta Bean Validation annotations to Product:
@NotBlankonname@Positiveonprice@Size(min=2, max=100)onname
Then add @Valid before the Product parameter in POST and PUT methods. GlassFish returns 400 Bad Request when validation fails.
Test: curl -X POST ... -d '{"name":"","price":-5}' โ 400.
Extension 4 โ CORS Filter for Frontend Clients
If a JavaScript frontend tries to call your API, the browser blocks it due to CORS restrictions. Create a JAX-RS ContainerResponseFilter:
@Provider
public class CorsFilter implements ContainerResponseFilter {
@Override
public void filter(ContainerRequestContext req,
ContainerResponseContext resp) {
resp.getHeaders().add("Access-Control-Allow-Origin", "*");
resp.getHeaders().add("Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS");
resp.getHeaders().add("Access-Control-Allow-Headers",
"Content-Type");
}
}
Test: Open a browser console and run:fetch('http://localhost:8080/ProductRestService/api/products').then(r => r.json()).then(console.log)