Custom Web Services
Overview
- What you’ll learn:
- How to create custom REST endpoints in iDempiere using JAX-RS annotations within an OSGi plugin
- How to handle JSON serialization, request validation, authentication, and error handling in your custom API endpoints
- How to register, test, version, and document your custom web services for external consumption
- Prerequisites: Lesson 28 — iDempiere REST API, Lesson 15 — Building Your First Plugin (OSGi development basics)
- Estimated reading time: 22 minutes
Introduction
While iDempiere’s built-in REST API provides generic CRUD access to Application Dictionary windows, real-world integrations often require specialized endpoints that encapsulate complex business logic. You might need an endpoint that checks product availability across multiple warehouses, creates a complete order with validation in a single call, or returns a custom data structure optimized for a specific external application.
iDempiere allows you to create custom REST endpoints as OSGi plugins, leveraging JAX-RS (Java API for RESTful Web Services) annotations and the same security infrastructure as the built-in API. In this lesson, you will learn how to build, register, test, and document your own web services. We will walk through two complete practical examples: a product lookup API and an order creation endpoint.
Architecture of Custom REST Endpoints
Custom REST endpoints in iDempiere follow a layered architecture:
- JAX-RS Resource Class: A Java class annotated with JAX-RS annotations that defines the URL paths, HTTP methods, and request/response handling.
- Business Logic Layer: Code that interacts with iDempiere’s model classes (MProduct, MOrder, MBPartner, etc.) to perform operations.
- OSGi Service Registration: The resource class is registered as an OSGi component so that iDempiere’s REST framework discovers and activates it.
The REST framework in iDempiere is built on Apache CXF, which provides the JAX-RS implementation. Your custom resource classes are automatically discovered when properly registered as OSGi declarative services.
Project Setup
Create a new OSGi plugin project in your Eclipse IDE (or your preferred IDE with PDE support):
- Create a new Plug-in Project named
com.example.rest. - Set the execution environment to JavaSE-17 (or your iDempiere target version).
- Add the following dependencies to your
MANIFEST.MF:
Require-Bundle: org.adempiere.base;bundle-version="11.0.0",
org.idempiere.rest.api;bundle-version="11.0.0"
Import-Package: javax.ws.rs;version="2.1.0",
javax.ws.rs.core;version="2.1.0",
javax.ws.rs.ext;version="2.1.0",
com.google.gson,
org.osgi.service.component.annotations
The org.idempiere.rest.api bundle provides base classes and the security infrastructure, while the javax.ws.rs packages provide the JAX-RS annotations.
JAX-RS Annotations
JAX-RS uses annotations to map Java methods to HTTP operations. Here are the essential annotations you will use:
Path and Method Annotations
import javax.ws.rs.*;
import javax.ws.rs.core.*;
@Path("v1/custom") // Base path for all endpoints in this class
public class CustomResource {
@GET // Responds to HTTP GET
@Path("products") // Full path: /api/v1/custom/products
@Produces(MediaType.APPLICATION_JSON)
public Response listProducts() {
// Implementation
}
@GET
@Path("products/{id}") // Path parameter
@Produces(MediaType.APPLICATION_JSON)
public Response getProduct(@PathParam("id") int productId) {
// Implementation
}
@POST // Responds to HTTP POST
@Path("orders")
@Consumes(MediaType.APPLICATION_JSON) // Accepts JSON input
@Produces(MediaType.APPLICATION_JSON) // Returns JSON output
public Response createOrder(String jsonBody) {
// Implementation
}
}
Parameter Annotations
@GET
@Path("products")
@Produces(MediaType.APPLICATION_JSON)
public Response searchProducts(
@QueryParam("name") String name, // ?name=value
@QueryParam("category") int categoryId, // ?category=123
@QueryParam("limit") @DefaultValue("50") int limit, // default if not provided
@HeaderParam("Authorization") String auth // HTTP header value
) {
// Implementation
}
JSON Serialization with Gson
iDempiere includes Google’s Gson library for JSON processing. Use it to serialize iDempiere model objects to JSON and deserialize incoming JSON to Java objects:
import com.google.gson.JsonObject;
import com.google.gson.JsonArray;
import com.google.gson.Gson;
// Building a JSON response manually
JsonObject json = new JsonObject();
json.addProperty("id", product.getM_Product_ID());
json.addProperty("value", product.getValue());
json.addProperty("name", product.getName());
json.addProperty("price", product.getPriceStd().doubleValue());
// Parsing incoming JSON
Gson gson = new Gson();
JsonObject request = gson.fromJson(jsonBody, JsonObject.class);
String customerValue = request.get("customerCode").getAsString();
Prefer building JSON objects explicitly rather than serializing entire model objects, as model classes contain internal fields and circular references that produce verbose or broken JSON output.
Authentication and Authorization
Your custom endpoints should enforce the same security as the built-in API. Extend the base class provided by the REST API plugin:
import org.idempiere.rest.api.v1.auth.filter.ITokenSecured;
@Path("v1/custom")
public class CustomResource implements ITokenSecured {
@GET
@Path("products")
@Produces(MediaType.APPLICATION_JSON)
public Response listProducts() {
// The ITokenSecured interface ensures that requests
// without a valid Bearer token are rejected with 401.
// The authenticated user's context (client, role, org)
// is automatically set.
Properties ctx = Env.getCtx();
int clientId = Env.getAD_Client_ID(ctx);
int orgId = Env.getAD_Org_ID(ctx);
// Now query using the authenticated context
// ...
}
}
The ITokenSecured marker interface hooks into iDempiere’s token authentication filter, which validates the Bearer token and populates the environment context (Env.getCtx()) with the authenticated user’s client, organization, role, and warehouse. If the token is missing or invalid, the framework returns a 401 Unauthorized response before your method executes.
Role-Based Access Control
You can enforce additional authorization within your endpoint methods:
@GET
@Path("sensitive-data")
@Produces(MediaType.APPLICATION_JSON)
public Response getSensitiveData() {
MRole role = MRole.getDefault(Env.getCtx(), false);
// Check if the role has access to a specific window
if (!role.isWindowAccess(WINDOW_ID)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"error\": \"Insufficient permissions\"}")
.build();
}
// Check table-level access
if (!role.isTableAccess(MProduct.Table_ID, false)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"error\": \"No access to product data\"}")
.build();
}
// Proceed with data retrieval
// ...
}
Request Validation and Error Handling
Robust request validation prevents invalid data from reaching your business logic layer:
@POST
@Path("orders")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response createOrder(String jsonBody) {
try {
// Parse and validate input
JsonObject request = new Gson().fromJson(jsonBody, JsonObject.class);
if (request == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"error\": \"Request body is required\"}")
.build();
}
// Validate required fields
if (!request.has("customerCode") || request.get("customerCode").getAsString().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"error\": \"customerCode is required\"}")
.build();
}
if (!request.has("lines") || request.getAsJsonArray("lines").size() == 0) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"error\": \"At least one order line is required\"}")
.build();
}
// Business logic here...
JsonObject result = processOrder(request);
return Response.status(Response.Status.CREATED).entity(result.toString()).build();
} catch (Exception e) {
// Log the error
CLogger.get().log(Level.SEVERE, "Order creation failed", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"error\": \"" + e.getMessage() + "\"}")
.build();
}
}
Registering Endpoints as OSGi Services
For iDempiere’s REST framework to discover your resource class, register it as an OSGi Declarative Service component. Create a component annotation on your resource class:
import org.osgi.service.component.annotations.Component;
@Component(
service = CustomResource.class,
property = {
"service.ranking:Integer=1"
},
immediate = true
)
@Path("v1/custom")
public class CustomResource implements ITokenSecured {
// ... endpoint methods
}
Alternatively, if your iDempiere version uses XML-based declarative services, create a component XML file in the OSGI-INF/ directory:
<?xml version="1.0" encoding="UTF-8"?>
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.3.0"
name="com.example.rest.CustomResource"
immediate="true">
<implementation class="com.example.rest.CustomResource"/>
<service>
<provide interface="com.example.rest.CustomResource"/>
</service>
</scr:component>
Register the component XML in your MANIFEST.MF:
Service-Component: OSGI-INF/CustomResource.xml
Additionally, register your resource class with iDempiere’s REST application. Create a resource finder class:
import org.idempiere.rest.api.v1.resource.IResourceFinder;
import org.osgi.service.component.annotations.Component;
@Component(
service = IResourceFinder.class,
immediate = true
)
public class CustomResourceFinder implements IResourceFinder {
@Override
public Class<?>[] getClasses() {
return new Class<?>[] { CustomResource.class };
}
}
CORS Configuration
If your custom API will be called from browser-based applications on different domains, configure CORS support. You can add a JAX-RS filter to handle CORS headers:
import javax.ws.rs.container.*;
import javax.ws.rs.ext.Provider;
@Provider
public class CORSFilter implements ContainerResponseFilter {
@Override
public void filter(ContainerRequestContext requestContext,
ContainerResponseContext responseContext) {
responseContext.getHeaders().add("Access-Control-Allow-Origin", "https://yourapp.example.com");
responseContext.getHeaders().add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
responseContext.getHeaders().add("Access-Control-Allow-Headers", "Authorization, Content-Type");
responseContext.getHeaders().add("Access-Control-Max-Age", "86400");
}
}
In production, never use a wildcard (*) for Access-Control-Allow-Origin. Always specify the exact domains that are permitted to make API requests.
Practical Example: Product Lookup API
Here is a complete example of a product lookup endpoint that returns product details with pricing and availability:
@Component(service = ProductLookupResource.class, immediate = true)
@Path("v1/custom")
public class ProductLookupResource implements ITokenSecured {
@GET
@Path("products/lookup")
@Produces(MediaType.APPLICATION_JSON)
public Response lookupProduct(
@QueryParam("value") String value,
@QueryParam("name") String name,
@QueryParam("limit") @DefaultValue("25") int limit) {
Properties ctx = Env.getCtx();
StringBuilder where = new StringBuilder("IsActive='Y' AND IsSold='Y'");
List<Object> params = new ArrayList<>();
if (value != null && !value.isEmpty()) {
where.append(" AND UPPER(Value) LIKE UPPER(?)");
params.add("%" + value + "%");
}
if (name != null && !name.isEmpty()) {
where.append(" AND UPPER(Name) LIKE UPPER(?)");
params.add("%" + name + "%");
}
List<MProduct> products = new Query(ctx, MProduct.Table_Name, where.toString(), null)
.setParameters(params)
.setOrderBy("Name")
.setApplyAccessFilter(MRole.SQL_FULLYQUALIFIED, MRole.SQL_RO)
.list();
JsonArray results = new JsonArray();
int count = 0;
for (MProduct product : products) {
if (count++ >= limit) break;
JsonObject obj = new JsonObject();
obj.addProperty("id", product.getM_Product_ID());
obj.addProperty("value", product.getValue());
obj.addProperty("name", product.getName());
obj.addProperty("uom", product.getC_UOM().getName());
obj.addProperty("productCategory", product.getM_Product_Category().getName());
obj.addProperty("isStocked", product.isStocked());
// Get standard price from the base price list
MProductPricing pricing = new MProductPricing(
product.getM_Product_ID(), 0, Env.ONE, true, null);
obj.addProperty("standardPrice", pricing.getPriceStd().doubleValue());
results.add(obj);
}
JsonObject response = new JsonObject();
response.addProperty("count", results.size());
response.add("products", results);
return Response.ok(response.toString()).build();
}
}
Test this endpoint with curl:
curl -X GET \
'http://localhost:8080/api/v1/custom/products/lookup?name=oak&limit=5' \
-H 'Authorization: Bearer <token>'
Practical Example: Order Creation Endpoint
This example creates a complete sales order with lines in a single API call, wrapped in a database transaction for atomicity:
@POST
@Path("orders/create")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response createSalesOrder(String jsonBody) {
Trx trx = null;
try {
JsonObject request = new Gson().fromJson(jsonBody, JsonObject.class);
// Validate input
String customerCode = request.get("customerCode").getAsString();
JsonArray lines = request.getAsJsonArray("lines");
if (lines.size() == 0)
return Response.status(400).entity("{\"error\":\"No order lines\"}").build();
Properties ctx = Env.getCtx();
String trxName = Trx.createTrxName("REST_Order");
trx = Trx.get(trxName, true);
// Look up business partner
MBPartner bp = new Query(ctx, MBPartner.Table_Name, "Value=?", trxName)
.setParameters(customerCode)
.setApplyAccessFilter(true)
.first();
if (bp == null) {
return Response.status(404)
.entity("{\"error\":\"Customer not found: " + customerCode + "\"}")
.build();
}
// Create order header
MOrder order = new MOrder(ctx, 0, trxName);
order.setAD_Org_ID(Env.getAD_Org_ID(ctx));
order.setC_DocTypeTarget_ID(MOrder.DocSubTypeSO_Standard);
order.setBPartner(bp);
order.setIsSOTrx(true);
order.setDateOrdered(new Timestamp(System.currentTimeMillis()));
if (!order.save()) {
trx.rollback();
return Response.status(500)
.entity("{\"error\":\"Failed to create order header\"}")
.build();
}
// Create order lines
for (int i = 0; i < lines.size(); i++) {
JsonObject lineJson = lines.get(i).getAsJsonObject();
MOrderLine line = new MOrderLine(order);
line.setM_Product_ID(lineJson.get("productId").getAsInt());
line.setQty(new BigDecimal(lineJson.get("quantity").getAsString()));
if (lineJson.has("price")) {
line.setPrice(new BigDecimal(lineJson.get("price").getAsString()));
}
if (!line.save()) {
trx.rollback();
return Response.status(500)
.entity("{\"error\":\"Failed to create line " + (i+1) + "\"}")
.build();
}
}
// Optionally complete the order
if (request.has("complete") && request.get("complete").getAsBoolean()) {
if (!order.processIt(DocAction.ACTION_Complete)) {
trx.rollback();
return Response.status(500)
.entity("{\"error\":\"" + order.getProcessMsg() + "\"}")
.build();
}
order.saveEx();
}
trx.commit();
// Build response
JsonObject result = new JsonObject();
result.addProperty("orderId", order.getC_Order_ID());
result.addProperty("documentNo", order.getDocumentNo());
result.addProperty("status", order.getDocStatus());
result.addProperty("grandTotal", order.getGrandTotal().doubleValue());
return Response.status(Response.Status.CREATED).entity(result.toString()).build();
} catch (Exception e) {
if (trx != null) trx.rollback();
CLogger.get().log(Level.SEVERE, "REST order creation failed", e);
return Response.status(500).entity("{\"error\":\"" + e.getMessage() + "\"}").build();
} finally {
if (trx != null) trx.close();
}
}
Call this endpoint:
curl -X POST \
http://localhost:8080/api/v1/custom/orders/create \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"customerCode": "JoeBlock",
"complete": true,
"lines": [
{ "productId": 134, "quantity": "10", "price": "25.00" },
{ "productId": 145, "quantity": "5" }
]
}'
Testing with curl and Postman
Effective testing is essential during API development. Here are strategies for both tools.
Testing with curl
Create a shell script to automate your testing workflow:
#!/bin/bash
BASE_URL="http://localhost:8080/api"
# Get token
TOKEN=$(curl -s -X PUT "$BASE_URL/v1/auth/tokens" \
-H 'Content-Type: application/json' \
-d '{"userName":"GardenAdmin","password":"GardenAdmin"}' | jq -r '.token')
# Set context
curl -s -X PUT "$BASE_URL/v1/auth/tokens" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"clientId":11,"roleId":102,"organizationId":11,"warehouseId":103}'
# Test product lookup
echo "--- Product Lookup ---"
curl -s -X GET "$BASE_URL/v1/custom/products/lookup?name=oak" \
-H "Authorization: Bearer $TOKEN" | jq .
# Test order creation
echo "--- Order Creation ---"
curl -s -X POST "$BASE_URL/v1/custom/orders/create" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"customerCode":"JoeBlock","lines":[{"productId":134,"quantity":"2"}]}' | jq .
Testing with Postman
Postman provides a visual interface for API testing. Create a Postman collection with environment variables for the token and base URL. Use Postman’s “Tests” tab to write assertions that validate response status codes, required fields, and data types. You can also use Postman’s collection runner to execute your test suite automatically.
API Versioning Best Practices
Version your APIs from the start to allow backward-compatible evolution:
- URL-based versioning: Include the version in the path (e.g.,
/api/v1/custom/products,/api/v2/custom/products). This is the approach used by iDempiere’s built-in API. - Deprecation policy: When releasing a new version, continue supporting the previous version for a defined period and include deprecation notices in response headers.
- Semantic meaning: Increment the major version for breaking changes (removed fields, changed response structure). Add new fields and endpoints without incrementing the version, as additive changes are backward-compatible.
API Documentation
Document your API endpoints for consumers using a standard format. Consider generating OpenAPI/Swagger documentation by annotating your resource class with Swagger annotations:
import io.swagger.annotations.*;
@Api(value = "Custom Product API", description = "Product lookup and search")
@Path("v1/custom")
public class ProductLookupResource {
@ApiOperation(value = "Search products", response = String.class)
@ApiResponses({
@ApiResponse(code = 200, message = "Products found"),
@ApiResponse(code = 401, message = "Unauthorized")
})
@GET
@Path("products/lookup")
@Produces(MediaType.APPLICATION_JSON)
public Response lookupProduct(
@ApiParam(value = "Product search code", required = false)
@QueryParam("value") String value) {
// ...
}
}
Even without Swagger annotations, maintain a simple document listing each endpoint’s URL, method, parameters, request body schema, response schema, and error codes. This documentation is essential for external developers consuming your API.
Summary
You now have the skills to extend iDempiere’s REST API with custom endpoints tailored to your integration requirements. You learned how to set up a plugin project, use JAX-RS annotations, handle JSON serialization, enforce authentication and authorization, validate requests, and register endpoints as OSGi services. The product lookup and order creation examples provide templates you can adapt for your own business logic. In the next lesson, we will explore performance tuning and caching strategies to ensure your iDempiere instance — including your custom APIs — performs optimally under load.