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.
繁體中文翻譯
概覽
- 您將學到:
- 如何在 OSGi 外掛中使用 JAX-RS 註解在 iDempiere 中建立自訂 REST 端點
- 如何在自訂 API 端點中處理 JSON 序列化、請求驗證、驗證和錯誤處理
- 如何為外部使用註冊、測試、版本控制和記錄自訂 Web 服務
- 先備知識: 第 28 課 — iDempiere REST API、第 15 課 — 建構您的第一個外掛(OSGi 開發基礎)
- 預估閱讀時間: 22 分鐘
簡介
雖然 iDempiere 的內建 REST API 提供了對應用程式字典視窗的通用 CRUD 存取,但實際的整合通常需要封裝複雜業務邏輯的專用端點。您可能需要一個端點來檢查多個倉庫的產品可用性、在單一呼叫中建立帶有驗證的完整訂單,或傳回針對特定外部應用程式最佳化的自訂資料結構。
iDempiere 允許您將自訂 REST 端點建立為 OSGi 外掛,利用 JAX-RS(Java API for RESTful Web Services)註解和與內建 API 相同的安全基礎設施。在本課中,您將學習如何建構、註冊、測試和記錄您自己的 Web 服務。我們將逐步介紹兩個完整的實務範例:產品查詢 API 和訂單建立端點。
自訂 REST 端點的架構
iDempiere 中的自訂 REST 端點遵循分層架構:
- JAX-RS 資源類別: 使用 JAX-RS 註解標註的 Java 類別,定義 URL 路徑、HTTP 方法和請求/回應處理。
- 業務邏輯層: 與 iDempiere 的模型類別(MProduct、MOrder、MBPartner 等)互動以執行操作的程式碼。
- OSGi 服務註冊: 資源類別註冊為 OSGi 元件,以便 iDempiere 的 REST 框架發現並啟用它。
iDempiere 中的 REST 框架建構在 Apache CXF 之上,提供 JAX-RS 實作。當正確註冊為 OSGi 宣告式服務時,您的自訂資源類別會被自動發現。
專案設定
在 Eclipse IDE(或支援 PDE 的首選 IDE)中建立新的 OSGi 外掛專案:
- 建立名為
com.example.rest的新外掛專案。 - 將執行環境設定為 JavaSE-17(或您的 iDempiere 目標版本)。
- 在
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
org.idempiere.rest.api 套件提供基礎類別和安全基礎設施,而 javax.ws.rs 套件提供 JAX-RS 註解。
JAX-RS 註解
JAX-RS 使用註解將 Java 方法對應到 HTTP 操作。以下是您將使用的基本註解:
路徑和方法註解
import javax.ws.rs.*;
import javax.ws.rs.core.*;
@Path("v1/custom") // 此類別中所有端點的基礎路徑
public class CustomResource {
@GET // 回應 HTTP GET
@Path("products") // 完整路徑:/api/v1/custom/products
@Produces(MediaType.APPLICATION_JSON)
public Response listProducts() {
// 實作
}
@GET
@Path("products/{id}") // 路徑參數
@Produces(MediaType.APPLICATION_JSON)
public Response getProduct(@PathParam("id") int productId) {
// 實作
}
@POST // 回應 HTTP POST
@Path("orders")
@Consumes(MediaType.APPLICATION_JSON) // 接受 JSON 輸入
@Produces(MediaType.APPLICATION_JSON) // 傳回 JSON 輸出
public Response createOrder(String jsonBody) {
// 實作
}
}
參數註解
@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, // 未提供時的預設值
@HeaderParam("Authorization") String auth // HTTP 標頭值
) {
// 實作
}
使用 Gson 進行 JSON 序列化
iDempiere 包含 Google 的 Gson 函式庫用於 JSON 處理。使用它將 iDempiere 模型物件序列化為 JSON,以及將傳入的 JSON 反序列化為 Java 物件:
import com.google.gson.JsonObject;
import com.google.gson.JsonArray;
import com.google.gson.Gson;
// 手動建構 JSON 回應
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());
// 解析傳入的 JSON
Gson gson = new Gson();
JsonObject request = gson.fromJson(jsonBody, JsonObject.class);
String customerValue = request.get("customerCode").getAsString();
建議明確建構 JSON 物件,而不是序列化整個模型物件,因為模型類別包含內部欄位和循環參照,會產生冗長或損壞的 JSON 輸出。
驗證與授權
您的自訂端點應該強制執行與內建 API 相同的安全性。擴展 REST API 外掛提供的基礎類別:
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() {
// ITokenSecured 介面確保沒有有效 Bearer 權杖的請求
// 會被拒絕並傳回 401。
// 已驗證使用者的上下文(用戶端、角色、組織)
// 會自動設定。
Properties ctx = Env.getCtx();
int clientId = Env.getAD_Client_ID(ctx);
int orgId = Env.getAD_Org_ID(ctx);
// 現在使用已驗證的上下文進行查詢
// ...
}
}
ITokenSecured 標記介面掛接到 iDempiere 的權杖驗證篩選器,該篩選器驗證 Bearer 權杖並使用已驗證使用者的用戶端、組織、角色和倉庫填充環境上下文(Env.getCtx())。如果權杖缺失或無效,框架會在您的方法執行之前傳回 401 Unauthorized 回應。
基於角色的存取控制
您可以在端點方法中強制執行額外的授權:
@GET
@Path("sensitive-data")
@Produces(MediaType.APPLICATION_JSON)
public Response getSensitiveData() {
MRole role = MRole.getDefault(Env.getCtx(), false);
// 檢查角色是否有存取特定視窗的權限
if (!role.isWindowAccess(WINDOW_ID)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"error\": \"Insufficient permissions\"}")
.build();
}
// 檢查表格層級的存取權限
if (!role.isTableAccess(MProduct.Table_ID, false)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"error\": \"No access to product data\"}")
.build();
}
// 繼續資料擷取
// ...
}
請求驗證和錯誤處理
健全的請求驗證可防止無效資料到達您的業務邏輯層:
@POST
@Path("orders")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response createOrder(String jsonBody) {
try {
// 解析和驗證輸入
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();
}
// 驗證必填欄位
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();
}
// 此處為業務邏輯...
JsonObject result = processOrder(request);
return Response.status(Response.Status.CREATED).entity(result.toString()).build();
} catch (Exception e) {
// 記錄錯誤
CLogger.get().log(Level.SEVERE, "Order creation failed", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"error\": \"" + e.getMessage() + "\"}")
.build();
}
}
將端點註冊為 OSGi 服務
要讓 iDempiere 的 REST 框架發現您的資源類別,請將其註冊為 OSGi 宣告式服務元件。在您的資源類別上建立元件註解:
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 {
// ... 端點方法
}
或者,如果您的 iDempiere 版本使用基於 XML 的宣告式服務,請在 OSGI-INF/ 目錄中建立元件 XML 檔案:
<?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>
在 MANIFEST.MF 中註冊元件 XML:
Service-Component: OSGI-INF/CustomResource.xml
此外,將您的資源類別註冊到 iDempiere 的 REST 應用程式。建立資源搜尋器類別:
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 設定
如果您的自訂 API 將從不同網域上的瀏覽器應用程式呼叫,請設定 CORS 支援。您可以加入 JAX-RS 篩選器來處理 CORS 標頭:
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");
}
}
在正式環境中,絕不要對 Access-Control-Allow-Origin 使用萬用字元(*)。始終指定允許發出 API 請求的確切網域。
實務範例:產品查詢 API
以下是一個完整的產品查詢端點範例,傳回包含定價和可用性的產品詳情:
@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());
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();
}
}
使用 curl 測試此端點:
curl -X GET \
'http://localhost:8080/api/v1/custom/products/lookup?name=oak&limit=5' \
-H 'Authorization: Bearer <token>'
實務範例:訂單建立端點
此範例在單一 API 呼叫中建立包含明細的完整銷售訂單,並包裹在資料庫交易中以確保原子性:
@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);
// 驗證輸入
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);
// 查詢業務夥伴
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();
}
// 建立訂單表頭
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();
}
// 建立訂單明細
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();
}
}
// 可選擇性地完成訂單
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();
// 建構回應
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();
}
}
呼叫此端點:
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" }
]
}'
使用 curl 和 Postman 測試
有效的測試在 API 開發期間至關重要。以下是兩種工具的策略。
使用 curl 測試
建立 Shell 腳本以自動化您的測試工作流程:
#!/bin/bash
BASE_URL="http://localhost:8080/api"
# 取得權杖
TOKEN=$(curl -s -X PUT "$BASE_URL/v1/auth/tokens" \
-H 'Content-Type: application/json' \
-d '{"userName":"GardenAdmin","password":"GardenAdmin"}' | jq -r '.token')
# 設定上下文
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}'
# 測試產品查詢
echo "--- 產品查詢 ---"
curl -s -X GET "$BASE_URL/v1/custom/products/lookup?name=oak" \
-H "Authorization: Bearer $TOKEN" | jq .
# 測試訂單建立
echo "--- 訂單建立 ---"
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 .
使用 Postman 測試
Postman 提供視覺化介面用於 API 測試。使用權杖和基礎 URL 的環境變數建立 Postman 集合。使用 Postman 的「Tests」頁籤撰寫斷言,驗證回應狀態碼、必填欄位和資料類型。您也可以使用 Postman 的集合執行器自動執行測試套件。
API 版本控制最佳實務
從一開始就對您的 API 進行版本控制,以允許向後相容的演進:
- 基於 URL 的版本控制: 在路徑中包含版本(例如
/api/v1/custom/products、/api/v2/custom/products)。這是 iDempiere 內建 API 使用的方法。 - 棄用政策: 發布新版本時,在定義的期間內繼續支援前一個版本,並在回應標頭中包含棄用通知。
- 語意化含義: 對重大變更(移除欄位、變更回應結構)遞增主要版本號。新增欄位和端點時不遞增版本號,因為新增的變更是向後相容的。
API 文件
使用標準格式為消費者記錄您的 API 端點。考慮透過使用 Swagger 註解標註您的資源類別來產生 OpenAPI/Swagger 文件:
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) {
// ...
}
}
即使沒有 Swagger 註解,也要維護一份簡單的文件,列出每個端點的 URL、方法、參數、請求主體結構、回應結構和錯誤碼。此文件對於使用您 API 的外部開發人員至關重要。
總結
您現在擁有使用針對整合需求量身打造的自訂端點來擴展 iDempiere REST API 的技能。您學習了如何設定外掛專案、使用 JAX-RS 註解、處理 JSON 序列化、強制驗證和授權、驗證請求,以及將端點註冊為 OSGi 服務。產品查詢和訂單建立範例提供了您可以根據自己的業務邏輯進行調整的範本。在下一課中,我們將探討效能調校和快取策略,以確保您的 iDempiere 實例(包括您的自訂 API)在負載下表現最佳。
日本語翻訳
概要
- 学習内容:
- OSGi プラグイン内で JAX-RS アノテーションを使用して iDempiere にカスタム REST エンドポイントを作成する方法
- カスタム API エンドポイントで JSON シリアライゼーション、リクエストバリデーション、認証、エラーハンドリングを処理する方法
- 外部利用のためにカスタム Web サービスを登録、テスト、バージョン管理、ドキュメント化する方法
- 前提条件: レッスン 28 — iDempiere REST API、レッスン 15 — 最初のプラグインの構築(OSGi 開発の基礎)
- 推定読了時間: 22 分
はじめに
iDempiere の組み込み REST API はアプリケーションディクショナリウィンドウへの汎用的な CRUD アクセスを提供しますが、実際の統合では、複雑なビジネスロジックをカプセル化した専用のエンドポイントが必要になることがよくあります。複数の倉庫にわたる製品の在庫状況を確認するエンドポイント、単一の呼び出しでバリデーション付きの完全な受注を作成するエンドポイント、特定の外部アプリケーション向けに最適化されたカスタムデータ構造を返すエンドポイントなどが必要になる場合があります。
iDempiere では、JAX-RS(Java API for RESTful Web Services)アノテーションと組み込み API と同じセキュリティインフラストラクチャを活用して、カスタム REST エンドポイントを OSGi プラグインとして作成できます。このレッスンでは、独自の Web サービスを構築、登録、テスト、ドキュメント化する方法を学びます。2 つの完全な実践例(製品検索 API と受注作成エンドポイント)を順を追って説明します。
カスタム REST エンドポイントのアーキテクチャ
iDempiere のカスタム REST エンドポイントはレイヤードアーキテクチャに従います:
- JAX-RS リソースクラス: JAX-RS アノテーションで注釈された Java クラスで、URL パス、HTTP メソッド、リクエスト/レスポンス処理を定義します。
- ビジネスロジック層: iDempiere のモデルクラス(MProduct、MOrder、MBPartner など)と対話して操作を実行するコードです。
- OSGi サービス登録: リソースクラスは OSGi コンポーネントとして登録され、iDempiere の REST フレームワークがそれを検出してアクティブ化します。
iDempiere の REST フレームワークは Apache CXF 上に構築されており、JAX-RS の実装を提供します。カスタムリソースクラスは、OSGi 宣言型サービスとして適切に登録されると自動的に検出されます。
プロジェクトのセットアップ
Eclipse IDE(または PDE をサポートする任意の IDE)で新しい OSGi プラグインプロジェクトを作成します:
com.example.restという名前の新しいプラグインプロジェクトを作成します。- 実行環境を JavaSE-17(または iDempiere のターゲットバージョン)に設定します。
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
org.idempiere.rest.api バンドルはベースクラスとセキュリティインフラストラクチャを提供し、javax.ws.rs パッケージは JAX-RS アノテーションを提供します。
JAX-RS アノテーション
JAX-RS はアノテーションを使用して Java メソッドを HTTP 操作にマッピングします。使用する基本的なアノテーションは以下の通りです:
パスとメソッドのアノテーション
import javax.ws.rs.*;
import javax.ws.rs.core.*;
@Path("v1/custom") // このクラス内の全エンドポイントのベースパス
public class CustomResource {
@GET // HTTP GET に応答
@Path("products") // フルパス:/api/v1/custom/products
@Produces(MediaType.APPLICATION_JSON)
public Response listProducts() {
// 実装
}
@GET
@Path("products/{id}") // パスパラメータ
@Produces(MediaType.APPLICATION_JSON)
public Response getProduct(@PathParam("id") int productId) {
// 実装
}
@POST // HTTP POST に応答
@Path("orders")
@Consumes(MediaType.APPLICATION_JSON) // JSON 入力を受け付ける
@Produces(MediaType.APPLICATION_JSON) // JSON 出力を返す
public Response createOrder(String jsonBody) {
// 実装
}
}
パラメータアノテーション
@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, // 未指定時のデフォルト値
@HeaderParam("Authorization") String auth // HTTP ヘッダー値
) {
// 実装
}
Gson による JSON シリアライゼーション
iDempiere には JSON 処理用の Google Gson ライブラリが含まれています。iDempiere のモデルオブジェクトを JSON にシリアライズしたり、受信した JSON を Java オブジェクトにデシリアライズしたりするために使用します:
import com.google.gson.JsonObject;
import com.google.gson.JsonArray;
import com.google.gson.Gson;
// JSON レスポンスを手動で構築
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());
// 受信した JSON を解析
Gson gson = new Gson();
JsonObject request = gson.fromJson(jsonBody, JsonObject.class);
String customerValue = request.get("customerCode").getAsString();
モデルクラスには内部フィールドや循環参照が含まれており、冗長または壊れた JSON 出力を生成するため、モデルオブジェクト全体をシリアライズするのではなく、JSON オブジェクトを明示的に構築することをお勧めします。
認証と認可
カスタムエンドポイントは、組み込み API と同じセキュリティを適用する必要があります。REST API プラグインが提供するベースクラスを拡張します:
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() {
// ITokenSecured インターフェースにより、有効な Bearer トークンのない
// リクエストは 401 で拒否されます。
// 認証済みユーザーのコンテキスト(クライアント、ロール、組織)
// は自動的に設定されます。
Properties ctx = Env.getCtx();
int clientId = Env.getAD_Client_ID(ctx);
int orgId = Env.getAD_Org_ID(ctx);
// 認証済みコンテキストを使用してクエリを実行
// ...
}
}
ITokenSecured マーカーインターフェースは iDempiere のトークン認証フィルターにフックし、Bearer トークンを検証して、認証済みユーザーのクライアント、組織、ロール、倉庫で環境コンテキスト(Env.getCtx())を設定します。トークンが欠落しているか無効な場合、フレームワークはメソッドの実行前に 401 Unauthorized レスポンスを返します。
ロールベースのアクセス制御
エンドポイントメソッド内で追加の認可を適用できます:
@GET
@Path("sensitive-data")
@Produces(MediaType.APPLICATION_JSON)
public Response getSensitiveData() {
MRole role = MRole.getDefault(Env.getCtx(), false);
// ロールが特定のウィンドウにアクセスできるか確認
if (!role.isWindowAccess(WINDOW_ID)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"error\": \"Insufficient permissions\"}")
.build();
}
// テーブルレベルのアクセスを確認
if (!role.isTableAccess(MProduct.Table_ID, false)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("{\"error\": \"No access to product data\"}")
.build();
}
// データ取得を続行
// ...
}
リクエストバリデーションとエラーハンドリング
堅牢なリクエストバリデーションにより、無効なデータがビジネスロジック層に到達するのを防ぎます:
@POST
@Path("orders")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response createOrder(String jsonBody) {
try {
// 入力を解析して検証
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();
}
// 必須フィールドの検証
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();
}
// ここにビジネスロジック...
JsonObject result = processOrder(request);
return Response.status(Response.Status.CREATED).entity(result.toString()).build();
} catch (Exception e) {
// エラーをログに記録
CLogger.get().log(Level.SEVERE, "Order creation failed", e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"error\": \"" + e.getMessage() + "\"}")
.build();
}
}
OSGi サービスとしてのエンドポイント登録
iDempiere の REST フレームワークがリソースクラスを検出するには、OSGi 宣言型サービスコンポーネントとして登録する必要があります。リソースクラスにコンポーネントアノテーションを作成します:
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 {
// ... エンドポイントメソッド
}
または、iDempiere のバージョンが XML ベースの宣言型サービスを使用している場合は、OSGI-INF/ ディレクトリにコンポーネント XML ファイルを作成します:
<?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>
MANIFEST.MF にコンポーネント XML を登録します:
Service-Component: OSGI-INF/CustomResource.xml
さらに、リソースクラスを iDempiere の REST アプリケーションに登録します。リソースファインダークラスを作成します:
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 設定
カスタム API が異なるドメインのブラウザベースのアプリケーションから呼び出される場合は、CORS サポートを設定します。JAX-RS フィルターを追加して CORS ヘッダーを処理できます:
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");
}
}
本番環境では、Access-Control-Allow-Origin にワイルドカード(*)を使用しないでください。API リクエストの送信を許可する正確なドメインを常に指定してください。
実践例:製品検索 API
以下は、価格と在庫状況を含む製品詳細を返す完全な製品検索エンドポイントの例です:
@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());
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();
}
}
curl でこのエンドポイントをテストします:
curl -X GET \
'http://localhost:8080/api/v1/custom/products/lookup?name=oak&limit=5' \
-H 'Authorization: Bearer <token>'
実践例:受注作成エンドポイント
この例では、単一の API 呼び出しで明細行付きの完全な受注を作成し、原子性のためにデータベーストランザクションでラップします:
@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);
// 入力を検証
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);
// ビジネスパートナーを検索
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();
}
// 受注ヘッダーを作成
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();
}
// 受注明細行を作成
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();
}
}
// オプションで受注を完了
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();
// レスポンスを構築
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();
}
}
このエンドポイントを呼び出します:
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" }
]
}'
curl と Postman によるテスト
効果的なテストは API 開発において不可欠です。両方のツールの戦略を紹介します。
curl によるテスト
テストワークフローを自動化するシェルスクリプトを作成します:
#!/bin/bash
BASE_URL="http://localhost:8080/api"
# トークンを取得
TOKEN=$(curl -s -X PUT "$BASE_URL/v1/auth/tokens" \
-H 'Content-Type: application/json' \
-d '{"userName":"GardenAdmin","password":"GardenAdmin"}' | jq -r '.token')
# コンテキストを設定
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}'
# 製品検索をテスト
echo "--- 製品検索 ---"
curl -s -X GET "$BASE_URL/v1/custom/products/lookup?name=oak" \
-H "Authorization: Bearer $TOKEN" | jq .
# 受注作成をテスト
echo "--- 受注作成 ---"
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 .
Postman によるテスト
Postman は API テスト用のビジュアルインターフェースを提供します。トークンとベース URL の環境変数を含む Postman コレクションを作成します。Postman の「Tests」タブを使用して、レスポンスステータスコード、必須フィールド、データ型を検証するアサーションを記述します。Postman のコレクションランナーを使用してテストスイートを自動的に実行することもできます。
API バージョニングのベストプラクティス
後方互換性のある進化を可能にするため、最初から API にバージョンを付けてください:
- URL ベースのバージョニング: パスにバージョンを含めます(例:
/api/v1/custom/products、/api/v2/custom/products)。これは iDempiere の組み込み API で使用されているアプローチです。 - 非推奨ポリシー: 新しいバージョンをリリースする際、定義された期間、前のバージョンのサポートを継続し、レスポンスヘッダーに非推奨の通知を含めます。
- セマンティックな意味: 破壊的変更(フィールドの削除、レスポンス構造の変更)にはメジャーバージョンをインクリメントします。新しいフィールドやエンドポイントの追加はバージョンをインクリメントせずに行います。追加的な変更は後方互換性があるためです。
API ドキュメント
標準的なフォーマットを使用して、利用者向けに API エンドポイントをドキュメント化します。Swagger アノテーションでリソースクラスを注釈して OpenAPI/Swagger ドキュメントを生成することを検討してください:
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) {
// ...
}
}
Swagger アノテーションがなくても、各エンドポイントの URL、メソッド、パラメータ、リクエストボディスキーマ、レスポンススキーマ、エラーコードを一覧にした簡単なドキュメントを維持してください。このドキュメントは、API を利用する外部開発者にとって不可欠です。
まとめ
統合要件に合わせたカスタムエンドポイントで iDempiere の REST API を拡張するスキルを身につけました。プラグインプロジェクトのセットアップ、JAX-RS アノテーションの使用、JSON シリアライゼーションの処理、認証と認可の適用、リクエストの検証、OSGi サービスとしてのエンドポイント登録の方法を学びました。製品検索と受注作成の例は、独自のビジネスロジックに適応できるテンプレートを提供します。次のレッスンでは、カスタム API を含む iDempiere インスタンスが負荷下で最適に動作するように、パフォーマンスチューニングとキャッシング戦略について探ります。