SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)

恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,123 @@
# OFBiz Modern API
`plugins/modern-api` is the REST contract layer for the Vue 3 + Element Plus rewrite.
## Endpoints
```text
POST /api/v1/login
POST /api/v1/logout
GET /api/v1/session
GET /api/v1/navigation
GET /api/v1/inventory
GET /api/v1/pages/:pageId
POST /api/v1/actions/:actionId
GET /api/v1/entities/:entityName
GET /api/v1/lookups/:lookupId
GET /api/v1/options/:entityName
POST /api/v1/uploads/:uploadId
```
All endpoints return:
```ts
type ApiResult<T> = {
ok: boolean
data?: T
errors?: Array<{ code: string; message: string; field?: string }>
messages?: string[]
warnings?: string[]
meta?: Record<string, unknown>
traceId: string
}
```
## Inventory
Generate the migration inventory:
```bash
cd /Users/qiu/Desktop/ERP/ofbiz-framework
node plugins/modern-api/scripts/generate-ui-inventory.mjs
```
Output:
```text
plugins/modern-api/generated/ui-inventory.json
plugins/modern-ui/app/public/generated/ui-inventory.json
plugins/modern-ui/app/public/generated/pages/{pageId}--{hash}.json
```
The generated file includes:
```text
routeManifest
pageDefinitions
actionDefinitions
controller/widget/service counts
coverage.missingRoutes
coverage.missingActions
coverage.pendingE2ePages
coverage.highRiskParityPages
parityManifest
```
Acceptance gates:
```text
missingRoutes=0
missingActions=0
generatedOnlyPages=0
frontend split PageDefinition files=pageDefinition count
pendingE2ePages=0 before final functional-equivalence signoff
```
Run the full structural coverage gate from the modern UI app:
```bash
cd /Users/qiu/Desktop/ERP/ofbiz-framework/plugins/modern-ui/app
npm run verify:coverage
```
It writes:
```text
plugins/modern-ui/verification/coverage-verification.json
plugins/modern-ui/verification/coverage-verification.md
```
`parityManifest` groups every generated page by domain, component, adapter, requirement, risk, and checklist progress. It is consumed by local verification/reporting. The production Vue app should remain an ERP administrator website, not a parity dashboard.
The full backend inventory keeps `pageDefinitions` and `actionDefinitions` for `/api/v1/*`. The frontend public index deliberately omits those large maps; each route has `pageDefinitionUrl`, and the SPA loads the exact page JSON on demand.
## Integration Notes
- `login` and `logout` run inside the `/api` web context so the modern UI can establish an OFBiz `userLogin` session for `/api/v1/*` requests.
- `actions/:actionId` maps v1 action IDs to OFBiz service names when possible and executes through `LocalDispatcher`.
- `entities/:entityName`, `lookups/:lookupId`, and `options/:entityName` require an OFBiz `userLogin` and OFBiz entity/business view permission. Business `_ADMIN` permissions and `ENTITY_DATA_ADMIN` imply view access.
- `navigation` returns deployable SPA links under `/modern/app/#/pages/...`.
- `lookups/:lookupId` maps v1 lookup IDs to OFBiz entity names, supports `query`, `page`, `pageSize`, and `orderBy`, and applies `query` across likely text/id/name/description/code/status fields.
- `uploads/:uploadId` is intentionally contract-only until the secure OFBiz upload policy is wired in.
## API Contract Evidence
The modern API is not a component showcase surface. It must provide enough backend evidence for the modern ERP UI to distinguish session state, permission failures, empty queues, and contract-only gaps.
| Endpoint group | Contract evidence | Current behavior |
| --- | --- | --- |
| `GET /api/v1/session` | `ModernApiContractTests.sessionEndpointReturnsUnauthenticatedContractInsteadOfAuthError` | Always returns `200 ok=true`; `data.authenticated=false` is the unauthenticated state instead of an auth error. |
| `GET /api/v1/navigation` | `NavigationResource` checks every webapp base permission through `ModernApiUtil.hasViewPermission`; `ModernApiUtilPermissionTests` covers `_VIEW`, `_ADMIN`, and null-user denial. | Navigation can include denied apps with `allowed=false`; UI must hide or disable them. |
| `GET /api/v1/pages/:pageId` and `GET /api/v1/inventory` | `UiInventoryLoader` backed contract documented by generated inventory acceptance gates above. | Returns generated metadata when present and a fallback page contract when missing; it is metadata, not proof of full legacy screen equivalence. |
| `POST /api/v1/actions/:actionId` | `ModernApiContractTests.serviceActionsRequireLoginBeforeExecution` | Service-backed actions require `userLogin` before dispatcher execution; unauthenticated calls return `401 AUTH_REQUIRED`. Navigation-only actions may resolve without service execution. |
| `GET /api/v1/entities/:entityName` | `ModernApiUtilPermissionTests` plus `ModernApiContractTests.clampsPaginationToModernApiBounds`, `reportsHasMoreWhenNextPageContainsRows`, and `keepsOnlyKnownOrderByFieldsAndFallsBackToPrimaryKey`. | Requires login and entity/business view permission, clamps `page >= 0`, clamps `1 <= pageSize <= 100`, returns `total`, `hasMore`, safe `orderBy`, fields, and an empty `rows` array when no records match. |
| `GET /api/v1/lookups/:lookupId` | Shares the same `ModernApiUtil.safePage`, `safePageSize`, `hasMore`, and `safeOrderByFields` contract as entities. | Requires login and entity/business view permission; lookup id maps to an entity name and returns paged rows/fields with safe ordering. |
| `GET /api/v1/options/:entityName` | Uses the same `ModernApiUtil.safePageSize` and entity permission checks as entity reads. | Requires login and entity/business view permission; returns `{label,value}` options, applied constraints, `pageSize`, and `hasMore`. |
| `POST /api/v1/uploads/:uploadId` | `ModernApiContractTests.uploadsRemainExplicitContractOnlyUntilPolicyIsImplemented` | Returns `501 UPLOAD_CONTRACT_ONLY`; no upload side effects happen until OFBiz secure upload policy is wired. |
Known non-equivalence remains explicit:
- `uploads/:uploadId` is contract-only and is not business-equivalent to legacy OFBiz upload flows.
- `pages/:pageId` and `inventory` prove route/widget/action metadata availability, not full rendered legacy behavior.
- `actions/:actionId` executes service-backed actions and supported web events, but controller flows that only resolve views remain navigation contracts.
- `entities`, `lookups`, and `options` provide secure generic read contracts; they do not replace every specialized OFBiz service, validation rule, or workflow-specific authorization branch.
@@ -0,0 +1,25 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
dependencies {
pluginLibsCompile 'org.glassfish.jersey.containers:jersey-container-servlet:2.48'
pluginLibsCompile 'org.glassfish.jersey.media:jersey-media-json-jackson:2.48'
pluginLibsCompile 'org.glassfish.jersey.media:jersey-media-multipart:2.48'
pluginLibsCompile 'org.glassfish.jersey.inject:jersey-hk2:2.48'
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<ofbiz-component name="modern-api" enabled="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://ofbiz.apache.org/dtds/ofbiz-component.xsd">
<resource-loader name="main" type="component"/>
<classpath type="dir" location="config"/>
<webapp name="modern-api"
title="Modern API"
position="2"
menu-name="secondary"
server="default-server"
location="webapp/modern-api"
base-permission="OFBTOOLS"
mount-point="/api"/>
</ofbiz-component>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const repoRoot = path.resolve(path.dirname(scriptPath), '../../..')
const inventoryPath = path.join(repoRoot, 'plugins/modern-api/generated/ui-inventory.json')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf8'))
function page(pageId) {
const definition = inventory.pageDefinitions?.[pageId]
assert.ok(definition, `Expected generated page definition for ${pageId}`)
return definition
}
function businessStatus(pageId) {
return page(pageId).acceptance?.businessParityStatus
}
function scenarioText(pageId) {
return JSON.stringify(page(pageId).acceptance?.e2eScenario || {})
}
assert.equal(
businessStatus('content__FindWebSite'),
'verified',
'read-only entity table pages with only navigation actions should be automatically business-verified'
)
assert.match(
scenarioText('content__FindWebSite'),
/只读实体表格/,
'read-only entity table pages should explain why they were auto-verified'
)
assert.equal(
businessStatus('content__WebSiteAliasesSearchResults'),
'pending-business-e2e',
'tables with destructive row links must stay pending until real business E2E verifies them'
)
assert.equal(
businessStatus('accounting__listInvoiceItems'),
'pending-business-e2e',
'pages with executable service/groovy actions must stay pending'
)
assert.equal(
businessStatus('content__FindForumThreads'),
'pending-business-e2e',
'mixed search pages with executable update services must stay pending'
)
console.log(JSON.stringify({
status: 'passed',
checked: [
'content__FindWebSite',
'content__WebSiteAliasesSearchResults',
'accounting__listInvoiceItems',
'content__FindForumThreads'
]
}, null, 2))
@@ -0,0 +1,76 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.core;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class ApiResult {
private ApiResult() { }
public static Map<String, Object> ok(Object data, String traceId) {
Map<String, Object> result = base(true, traceId);
result.put("data", data);
return result;
}
public static Map<String, Object> ok(Object data, List<String> messages, Map<String, Object> meta, String traceId) {
Map<String, Object> result = ok(data, traceId);
if (messages != null && !messages.isEmpty()) {
result.put("messages", messages);
}
if (meta != null && !meta.isEmpty()) {
result.put("meta", meta);
}
return result;
}
public static Map<String, Object> error(String code, String message, String traceId) {
return error(code, message, null, traceId);
}
public static Map<String, Object> error(String code, String message, String field, String traceId) {
Map<String, Object> result = base(false, traceId);
List<Map<String, Object>> errors = new ArrayList<>();
Map<String, Object> error = new LinkedHashMap<>();
error.put("code", code);
error.put("message", message);
if (field != null && !field.isEmpty()) {
error.put("field", field);
}
errors.add(error);
result.put("errors", errors);
return result;
}
public static Map<String, Object> warning(Object data, String warning, String traceId) {
Map<String, Object> result = ok(data, traceId);
result.put("warnings", List.of(warning));
return result;
}
private static Map<String, Object> base(boolean ok, String traceId) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", ok);
result.put("traceId", traceId);
return result;
}
}
@@ -0,0 +1,72 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.core;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.webapp.control.LoginWorker;
@SuppressWarnings("serial")
public class ModernApiAuthFilter extends HttpFilter {
@FunctionalInterface
interface SecuredLoginBridge {
String bridge(HttpServletRequest request, HttpServletResponse response);
}
private final SecuredLoginBridge securedLoginBridge;
public ModernApiAuthFilter() {
this(LoginWorker::securedUserLoginByJWTCookie);
}
ModernApiAuthFilter(SecuredLoginBridge securedLoginBridge) {
this.securedLoginBridge = securedLoginBridge;
}
@Override
public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
seedOfbizContext(request);
if (ModernApiUtil.userLogin(request) == null) {
securedLoginBridge.bridge(request, response);
}
chain.doFilter(request, response);
}
private void seedOfbizContext(HttpServletRequest request) {
ServletContext servletContext = request.getServletContext();
copyContextAttribute(request, servletContext, "delegator");
copyContextAttribute(request, servletContext, "dispatcher");
copyContextAttribute(request, servletContext, "security");
}
private void copyContextAttribute(HttpServletRequest request, ServletContext servletContext, String name) {
Object value = servletContext.getAttribute(name);
if (value != null && request.getAttribute(name) == null) {
request.setAttribute(name, value);
}
}
}
@@ -0,0 +1,40 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.core;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.ofbiz.base.util.Debug;
import org.glassfish.jersey.jackson.JacksonFeature;
import org.glassfish.jersey.logging.LoggingFeature;
import org.glassfish.jersey.media.multipart.MultiPartFeature;
import org.glassfish.jersey.server.ResourceConfig;
public class ModernApiConfig extends ResourceConfig {
public ModernApiConfig() {
packages("org.apache.ofbiz.modernapi.resources");
register(JacksonFeature.class);
register(MultiPartFeature.class);
if (Debug.verboseOn()) {
register(new LoggingFeature(Logger.getLogger(LoggingFeature.DEFAULT_LOGGER_NAME), Level.INFO,
LoggingFeature.Verbosity.PAYLOAD_ANY, 10000));
}
}
}
@@ -0,0 +1,52 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.core;
import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.service.LocalDispatcher;
import org.apache.ofbiz.webapp.WebAppUtil;
public class ModernApiContextListener implements ServletContextListener {
private static final String MODULE = ModernApiContextListener.class.getName();
@Override
public void contextInitialized(ServletContextEvent sce) {
ServletContext servletContext = sce.getServletContext();
Delegator delegator = WebAppUtil.getDelegator(servletContext);
LocalDispatcher dispatcher = WebAppUtil.getDispatcher(servletContext);
servletContext.setAttribute("delegator", delegator);
servletContext.setAttribute("dispatcher", dispatcher);
servletContext.setAttribute("security", WebAppUtil.getSecurity(servletContext));
Debug.logInfo("Modern API context initialized, delegator " + delegator + ", dispatcher " + dispatcher, MODULE);
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
ServletContext servletContext = sce.getServletContext();
servletContext.removeAttribute("delegator");
servletContext.removeAttribute("dispatcher");
servletContext.removeAttribute("security");
Debug.logInfo("Modern API context destroyed", MODULE);
}
}
@@ -0,0 +1,238 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.core;
import java.math.BigDecimal;
import java.sql.Time;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.entity.model.ModelEntity;
import org.apache.ofbiz.security.Security;
import org.apache.ofbiz.service.LocalDispatcher;
public final class ModernApiUtil {
private ModernApiUtil() { }
public static String traceId() {
return UUID.randomUUID().toString();
}
public static Response ok(Object data, String traceId) {
return Response.ok(ApiResult.ok(data, traceId)).build();
}
public static Response error(Response.Status status, String code, String message, String traceId) {
return Response.status(status).entity(ApiResult.error(code, message, traceId)).build();
}
public static Delegator delegator(ServletContext servletContext) {
return (Delegator) servletContext.getAttribute("delegator");
}
public static LocalDispatcher dispatcher(ServletContext servletContext) {
return (LocalDispatcher) servletContext.getAttribute("dispatcher");
}
public static Security security(ServletContext servletContext) {
return (Security) servletContext.getAttribute("security");
}
public static GenericValue userLogin(HttpServletRequest request) {
Object requestUserLogin = request.getAttribute("userLogin");
if (requestUserLogin instanceof GenericValue) {
return (GenericValue) requestUserLogin;
}
Object sessionUserLogin = request.getSession(false) == null ? null : request.getSession(false).getAttribute("userLogin");
return sessionUserLogin instanceof GenericValue ? (GenericValue) sessionUserLogin : null;
}
public static boolean hasViewPermission(Security security, GenericValue userLogin, String permission) {
if (permission == null || permission.isEmpty() || "NONE".equals(permission)) {
return true;
}
if (security == null || userLogin == null) {
return false;
}
return security.hasEntityPermission(permission, "_VIEW", userLogin)
|| security.hasEntityPermission(permission, "_ADMIN", userLogin)
|| security.hasPermission(permission + "_VIEW", userLogin)
|| security.hasPermission(permission + "_ADMIN", userLogin);
}
public static boolean hasEntityViewPermission(Security security, GenericValue userLogin, ModelEntity modelEntity) {
if (security == null || userLogin == null || modelEntity == null) {
return false;
}
if (hasViewPermission(security, userLogin, "ENTITY_DATA")) {
return true;
}
for (String permission : entityViewPermissions(modelEntity)) {
if (hasViewPermission(security, userLogin, permission)) {
return true;
}
}
return false;
}
public static int safePage(int page) {
return Math.max(0, page);
}
public static int safePageSize(int pageSize) {
return Math.max(1, Math.min(pageSize, 100));
}
public static boolean hasMore(int page, int pageSize, long total) {
long safeTotal = Math.max(0, total);
return ((long) (safePage(page) + 1) * safePageSize(pageSize)) < safeTotal;
}
public static Map<String, Object> pagination(int page, int pageSize, long total) {
Map<String, Object> data = new LinkedHashMap<>();
int safePage = safePage(page);
int safePageSize = safePageSize(pageSize);
long safeTotal = Math.max(0, total);
data.put("page", safePage);
data.put("pageSize", safePageSize);
data.put("total", safeTotal);
data.put("hasMore", hasMore(safePage, safePageSize, safeTotal));
return data;
}
public static List<String> safeOrderByFields(Collection<String> fieldNames, List<String> pkFieldNames, String orderBy) {
List<String> safeFields = new ArrayList<>();
Set<String> allowedFields = new HashSet<>(fieldNames);
if (orderBy != null && !orderBy.isBlank()) {
for (String rawField : orderBy.split(",")) {
String field = rawField.trim();
if (field.isEmpty()) {
continue;
}
String upper = field.toUpperCase(Locale.ROOT);
String suffix = "";
if (upper.endsWith(" DESC")) {
suffix = " DESC";
field = field.substring(0, field.length() - 5).trim();
} else if (upper.endsWith(" ASC")) {
suffix = " ASC";
field = field.substring(0, field.length() - 4).trim();
} else if (field.startsWith("-")) {
suffix = " DESC";
field = field.substring(1).trim();
}
if (allowedFields.contains(field)) {
safeFields.add(field + suffix);
}
}
}
if (!safeFields.isEmpty()) {
return safeFields;
}
return new ArrayList<>(pkFieldNames);
}
private static List<String> entityViewPermissions(ModelEntity modelEntity) {
List<String> permissions = new ArrayList<>();
String packageName = modelEntity.getPackageName() == null ? "" : modelEntity.getPackageName().toLowerCase(Locale.ROOT);
addPackagePermission(permissions, packageName, ".order.", "ORDERMGR");
addPackagePermission(permissions, packageName, ".party.", "PARTYMGR");
addPackagePermission(permissions, packageName, ".product.", "CATALOG");
addPackagePermission(permissions, packageName, ".shipment.", "FACILITY");
addPackagePermission(permissions, packageName, ".accounting.", "ACCOUNTING");
addPackagePermission(permissions, packageName, ".humanres.", "HUMANRES");
addPackagePermission(permissions, packageName, ".manufacturing.", "MANUFACTURING");
addPackagePermission(permissions, packageName, ".workeffort.", "WORKEFFORTMGR");
addPackagePermission(permissions, packageName, ".content.", "CONTENTMGR");
addPackagePermission(permissions, packageName, ".marketing.", "MARKETING");
addPackagePermission(permissions, packageName, ".webapp.website", "CONTENTMGR");
addPackagePermission(permissions, packageName, ".security.", "SECURITY");
addPackagePermission(permissions, packageName, ".webtools.", "WEBTOOLS");
if (isCommonReferenceEntity(modelEntity)) {
addPermission(permissions, "ORDERMGR");
addPermission(permissions, "PARTYMGR");
addPermission(permissions, "CATALOG");
addPermission(permissions, "FACILITY");
addPermission(permissions, "ACCOUNTING");
addPermission(permissions, "HUMANRES");
addPermission(permissions, "MANUFACTURING");
addPermission(permissions, "WORKEFFORTMGR");
addPermission(permissions, "CONTENTMGR");
}
return permissions;
}
private static void addPackagePermission(List<String> permissions, String packageName, String marker, String permission) {
if (packageName.contains(marker)) {
addPermission(permissions, permission);
}
}
private static void addPermission(List<String> permissions, String permission) {
if (!permissions.contains(permission)) {
permissions.add(permission);
}
}
private static boolean isCommonReferenceEntity(ModelEntity modelEntity) {
String packageName = modelEntity.getPackageName() == null ? "" : modelEntity.getPackageName().toLowerCase(Locale.ROOT);
if (!packageName.contains(".common.")) {
return false;
}
String entityName = modelEntity.getEntityName();
return entityName != null && (entityName.endsWith("Type") || entityName.endsWith("Item") || entityName.endsWith("Enum")
|| entityName.endsWith("Enumeration") || entityName.endsWith("Geo") || entityName.endsWith("Uom"));
}
public static Map<String, Object> publicFields(GenericValue value) {
Map<String, Object> fields = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : value.getAllFields().entrySet()) {
fields.put(entry.getKey(), serializableValue(entry.getValue()));
}
return fields;
}
public static Object serializableValue(Object value) {
if (value instanceof Timestamp || value instanceof Time || value instanceof java.sql.Date) {
return value.toString();
}
if (value instanceof Date) {
return Long.toString(((Date) value).getTime());
}
if (value instanceof BigDecimal) {
return value.toString();
}
return value;
}
}
@@ -0,0 +1,139 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.core;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ofbiz.base.location.FlexibleLocation;
import org.apache.ofbiz.base.util.Debug;
public final class UiInventoryLoader {
private static final String MODULE = UiInventoryLoader.class.getName();
private static final ObjectMapper MAPPER = new ObjectMapper();
private static Map<String, Object> cachedInventory;
private static String cachedInventoryPath;
private static long cachedInventoryLastModified = -1;
private UiInventoryLoader() { }
public static synchronized Map<String, Object> load() {
File generated = new File("plugins/modern-api/generated/ui-inventory.json");
if (!generated.exists()) {
try {
URL generatedUrl = FlexibleLocation.resolveLocation("component://modern-api/generated/ui-inventory.json");
generated = new File(generatedUrl.getPath());
} catch (Exception e) {
Debug.logWarning(e, "Modern UI inventory has not been generated yet", MODULE);
}
}
if (!generated.exists()) {
if (cachedInventory != null && cachedInventoryPath == null) {
return cachedInventory;
}
cachedInventory = emptyInventory();
cachedInventoryPath = null;
cachedInventoryLastModified = -1;
return cachedInventory;
}
String inventoryPath = generated.getAbsolutePath();
long inventoryLastModified = generated.lastModified();
if (cachedInventory != null
&& inventoryPath.equals(cachedInventoryPath)
&& inventoryLastModified == cachedInventoryLastModified) {
return cachedInventory;
}
try {
cachedInventory = MAPPER.readValue(generated, new TypeReference<Map<String, Object>>() { });
cachedInventoryPath = inventoryPath;
cachedInventoryLastModified = inventoryLastModified;
} catch (IOException e) {
Debug.logError(e, "Unable to read Modern UI inventory", MODULE);
cachedInventory = emptyInventory();
cachedInventoryPath = inventoryPath;
cachedInventoryLastModified = inventoryLastModified;
}
return cachedInventory;
}
@SuppressWarnings("unchecked")
public static Map<String, Object> findPage(String pageId) {
Object pageDefinitions = load().get("pageDefinitions");
if (!(pageDefinitions instanceof Map)) {
return Collections.emptyMap();
}
Object page = ((Map<String, Object>) pageDefinitions).get(pageId);
return page instanceof Map ? (Map<String, Object>) page : Collections.emptyMap();
}
@SuppressWarnings("unchecked")
public static Map<String, Object> findAction(String actionId) {
Object actionDefinitions = load().get("actionDefinitions");
if (!(actionDefinitions instanceof Map)) {
return Collections.emptyMap();
}
Object action = ((Map<String, Object>) actionDefinitions).get(actionId);
return action instanceof Map ? (Map<String, Object>) action : Collections.emptyMap();
}
private static Map<String, Object> emptyInventory() {
Map<String, Object> inventory = new LinkedHashMap<>();
inventory.put("generatedAt", null);
inventory.put("counts", Collections.emptyMap());
inventory.put("controllers", Collections.emptyList());
inventory.put("widgets", Collections.emptyList());
inventory.put("services", Collections.emptyList());
inventory.put("pageDefinitions", Collections.emptyMap());
inventory.put("routeManifest", Collections.emptyList());
inventory.put("actionDefinitions", Collections.emptyMap());
inventory.put("parityManifest", emptyParityManifest());
return inventory;
}
private static Map<String, Object> emptyParityManifest() {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("totalPages", 0);
summary.put("elementPlusRenderablePages", 0);
summary.put("routeRenderedPages", 0);
summary.put("adapterCoveredPages", 0);
summary.put("customVueRequiredPages", 0);
summary.put("pendingE2ePages", 0);
summary.put("templateParityPages", 0);
summary.put("highRiskPages", 0);
summary.put("mediumRiskPages", 0);
summary.put("lowRiskPages", 0);
Map<String, Object> manifest = new LinkedHashMap<>();
manifest.put("summary", summary);
manifest.put("byDomain", Collections.emptyList());
manifest.put("byComponent", Collections.emptyList());
manifest.put("byAdapter", Collections.emptyList());
manifest.put("byRequirement", Collections.emptyList());
manifest.put("byRisk", Collections.emptyMap());
manifest.put("pages", Collections.emptyList());
return manifest;
}
}
@@ -0,0 +1,403 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.resources;
import java.io.IOException;
import java.util.Collections;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.modernapi.core.ApiResult;
import org.apache.ofbiz.modernapi.core.ModernApiUtil;
import org.apache.ofbiz.modernapi.core.UiInventoryLoader;
import org.apache.ofbiz.service.GenericServiceException;
import org.apache.ofbiz.service.LocalDispatcher;
import org.apache.ofbiz.service.ModelParam;
import org.apache.ofbiz.service.ModelService;
import org.apache.ofbiz.service.ServiceUtil;
import org.apache.ofbiz.webapp.control.ConfigXMLReader;
import org.apache.ofbiz.webapp.event.EventHandler;
import org.apache.ofbiz.webapp.event.EventHandlerException;
import org.apache.ofbiz.webapp.event.GroovyEventHandler;
import org.apache.ofbiz.webapp.event.JavaEventHandler;
import org.apache.ofbiz.webapp.event.RomeEventHandler;
import org.apache.ofbiz.webapp.event.ServiceMultiEventHandler;
import org.apache.ofbiz.webapp.event.SimpleEventHandler;
@Path("/v1/actions")
@Produces(MediaType.APPLICATION_JSON)
public class ActionResource {
@Context private ServletContext servletContext;
@Context private HttpServletRequest request;
@Context private HttpServletResponse response;
@POST
@Path("/{actionId}")
@Consumes(MediaType.APPLICATION_JSON)
public Response runAction(@PathParam("actionId") String actionId, Map<String, Object> payload) {
String traceId = ModernApiUtil.traceId();
LocalDispatcher dispatcher = ModernApiUtil.dispatcher(servletContext);
GenericValue userLogin = ModernApiUtil.userLogin(request);
Map<String, Object> actionDefinition = UiInventoryLoader.findAction(actionId);
Map<String, Object> input = payload == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload);
String eventType = stringValue(actionDefinition.get("eventType"));
if (isWebEvent(eventType)) {
return runWebEvent(actionId, actionDefinition, input, userLogin, traceId);
}
Map<String, Object> navigationTarget = navigationTarget(actionDefinition);
if (!navigationTarget.isEmpty()) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("actionId", actionId);
data.put("definition", actionDefinition);
data.put("executed", true);
data.put("actionType", "navigation");
data.put("navigationTarget", navigationTarget.get("modernPath"));
data.put("navigationPageId", navigationTarget.get("pageId"));
data.put("legacyResponse", navigationTarget);
data.put("payload", input);
data.put("reason", "Legacy controller request resolves to a view response and is handled as modern SPA navigation.");
return ModernApiUtil.ok(data, traceId);
}
String serviceName = serviceNameFor(actionId, actionDefinition);
if (serviceName == null || serviceName.isBlank()) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("actionId", actionId);
data.put("definition", actionDefinition);
data.put("executed", false);
data.put("reason", "Only OFBiz service actions and supported web events can be executed through /api/v1/actions.");
return ModernApiUtil.ok(data, traceId);
}
if (userLogin == null) {
return ModernApiUtil.error(Response.Status.UNAUTHORIZED, "AUTH_REQUIRED", "A valid OFBiz userLogin is required to execute actions.", traceId);
}
if (dispatcher == null) {
return ModernApiUtil.error(Response.Status.SERVICE_UNAVAILABLE, "DISPATCHER_UNAVAILABLE", "OFBiz dispatcher is not available.", traceId);
}
input.put("userLogin", userLogin);
try {
Map<String, Object> serviceContext = dispatcher.getDispatchContext().makeValidContext(serviceName, ModelService.IN_PARAM, input);
Map<String, Object> serviceResult = dispatcher.runSync(serviceName, serviceContext);
if (!ServiceUtil.isSuccess(serviceResult)) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(ApiResult.error("SERVICE_ERROR", ServiceUtil.getErrorMessage(serviceResult), traceId))
.build();
}
ModelService service = dispatcher.getDispatchContext().getModelService(serviceName);
Set<String> outParamNames = service.getOutParamNames();
Map<String, Object> data = new LinkedHashMap<>();
data.put("actionId", actionId);
data.put("serviceName", serviceName);
data.put("executed", true);
Map<String, Object> outParams = new LinkedHashMap<>();
for (String outParamName : outParamNames) {
ModelParam outParam = service.getParam(outParamName);
if (!outParam.isInternal() && serviceResult.containsKey(outParamName)) {
outParams.put(outParamName, ModernApiUtil.serializableValue(serviceResult.get(outParamName)));
}
}
data.put("result", outParams);
return ModernApiUtil.ok(data, traceId);
} catch (GenericServiceException e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(ApiResult.error("SERVICE_EXCEPTION", e.getMessage(), traceId))
.build();
}
}
private Response runWebEvent(String actionId, Map<String, Object> actionDefinition,
Map<String, Object> payload, GenericValue userLogin, String traceId) {
if (requiresAuth(actionDefinition) && userLogin == null) {
return ModernApiUtil.error(Response.Status.UNAUTHORIZED, "AUTH_REQUIRED", "A valid OFBiz userLogin is required to execute actions.", traceId);
}
String eventType = stringValue(actionDefinition.get("eventType"));
String eventPath = stringValue(actionDefinition.get("eventPath"));
String eventInvoke = stringValue(actionDefinition.get("eventInvoke"));
if (eventInvoke.isBlank()) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("actionId", actionId);
data.put("definition", actionDefinition);
data.put("executed", false);
data.put("reason", "The legacy controller action has no executable event invoke target.");
return ModernApiUtil.ok(data, traceId);
}
try {
EventHandler handler = eventHandler(eventType);
HttpServletRequest eventRequest = new PayloadRequestWrapper(request, payload);
CapturingResponseWrapper eventResponse = new CapturingResponseWrapper(response);
seedRequestContext(eventRequest, userLogin);
ConfigXMLReader.Event event = new ConfigXMLReader.Event(eventType, eventPath, eventInvoke, true);
String eventReturn = handler.invoke(event, null, eventRequest, eventResponse);
Map<String, Object> data = new LinkedHashMap<>();
data.put("actionId", actionId);
data.put("eventType", eventType);
data.put("eventPath", eventPath);
data.put("eventInvoke", eventInvoke);
data.put("eventReturn", eventReturn);
data.put("executed", !"error".equalsIgnoreCase(eventReturn));
data.put("redirectLocation", eventResponse.redirectLocation());
data.put("httpStatus", eventResponse.status());
data.put("messages", eventMessages(eventRequest));
data.put("payload", payload);
if ("error".equalsIgnoreCase(eventReturn)) {
data.put("reason", firstMessage(eventRequest, "Legacy event returned error."));
}
return ModernApiUtil.ok(data, traceId);
} catch (EventHandlerException | RuntimeException e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(ApiResult.error("EVENT_EXCEPTION", e.getMessage(), traceId))
.build();
}
}
private void seedRequestContext(HttpServletRequest eventRequest, GenericValue userLogin) {
eventRequest.setAttribute("delegator", ModernApiUtil.delegator(servletContext));
eventRequest.setAttribute("dispatcher", ModernApiUtil.dispatcher(servletContext));
eventRequest.setAttribute("security", ModernApiUtil.security(servletContext));
if (userLogin != null) {
eventRequest.setAttribute("userLogin", userLogin);
eventRequest.getSession().setAttribute("userLogin", userLogin);
}
}
private Map<String, Object> eventMessages(HttpServletRequest eventRequest) {
Map<String, Object> messages = new LinkedHashMap<>();
copyRequestAttribute(eventRequest, messages, "_EVENT_MESSAGE_");
copyRequestAttribute(eventRequest, messages, "_EVENT_MESSAGE_LIST_");
copyRequestAttribute(eventRequest, messages, "_ERROR_MESSAGE_");
copyRequestAttribute(eventRequest, messages, "_ERROR_MESSAGE_LIST_");
return messages;
}
private void copyRequestAttribute(HttpServletRequest eventRequest, Map<String, Object> messages, String name) {
Object value = eventRequest.getAttribute(name);
if (value != null) {
messages.put(name, ModernApiUtil.serializableValue(value));
}
}
private String firstMessage(HttpServletRequest eventRequest, String fallback) {
for (String name : List.of("_ERROR_MESSAGE_", "_EVENT_MESSAGE_")) {
Object value = eventRequest.getAttribute(name);
if (value != null && !value.toString().isBlank()) {
return value.toString();
}
}
return fallback;
}
private EventHandler eventHandler(String eventType) throws EventHandlerException {
EventHandler handler;
switch (eventType) {
case "java":
handler = new JavaEventHandler();
break;
case "simple":
handler = new SimpleEventHandler();
break;
case "groovy":
handler = new GroovyEventHandler();
break;
case "service-multi":
handler = new ServiceMultiEventHandler();
break;
case "rome":
handler = new RomeEventHandler();
break;
default:
throw new EventHandlerException("Unsupported event type: " + eventType);
}
handler.init(servletContext);
return handler;
}
private boolean isWebEvent(String eventType) {
return Set.of("java", "simple", "groovy", "service-multi", "rome").contains(eventType);
}
private boolean requiresAuth(Map<String, Object> actionDefinition) {
Object auth = actionDefinition.get("auth");
return auth instanceof Boolean ? (Boolean) auth : true;
}
@SuppressWarnings("unchecked")
private Map<String, Object> navigationTarget(Map<String, Object> actionDefinition) {
String eventType = stringValue(actionDefinition.get("eventType"));
if (!eventType.isBlank()) {
return Collections.emptyMap();
}
Object responses = actionDefinition.get("responses");
if (!(responses instanceof Iterable<?>)) {
return Collections.emptyMap();
}
for (Object response : (Iterable<?>) responses) {
if (!(response instanceof Map<?, ?>)) {
continue;
}
Map<String, Object> responseMap = (Map<String, Object>) response;
if ("view".equals(responseMap.get("type")) && responseMap.get("modernPath") != null) {
return responseMap;
}
}
return Collections.emptyMap();
}
private String serviceNameFor(String actionId, Map<String, Object> actionDefinition) {
if (actionDefinition == null || actionDefinition.isEmpty()) {
return actionId;
}
Object serviceName = actionDefinition.get("serviceName");
if (serviceName instanceof String && !((String) serviceName).isBlank()) {
return (String) serviceName;
}
Object eventType = actionDefinition.get("eventType");
Object eventInvoke = actionDefinition.get("eventInvoke");
if ("service".equals(eventType) && eventInvoke instanceof String) {
return (String) eventInvoke;
}
if ("service".equals(actionDefinition.get("source"))) {
return actionId;
}
return "";
}
private String stringValue(Object value) {
return value == null ? "" : value.toString();
}
private static final class PayloadRequestWrapper extends HttpServletRequestWrapper {
private final Map<String, String[]> parameters = new LinkedHashMap<>();
private PayloadRequestWrapper(HttpServletRequest request, Map<String, Object> payload) {
super(request);
parameters.putAll(request.getParameterMap());
for (Map.Entry<String, Object> entry : payload.entrySet()) {
parameters.put(entry.getKey(), stringArray(entry.getValue()));
}
}
@Override
public String getParameter(String name) {
String[] values = parameters.get(name);
return values == null || values.length == 0 ? null : values[0];
}
@Override
public Map<String, String[]> getParameterMap() {
return Collections.unmodifiableMap(parameters);
}
@Override
public Enumeration<String> getParameterNames() {
return Collections.enumeration(parameters.keySet());
}
@Override
public String[] getParameterValues(String name) {
return parameters.get(name);
}
private static String[] stringArray(Object value) {
if (value == null) {
return new String[] { "" };
}
if (value instanceof Iterable<?>) {
return iterableToArray((Iterable<?>) value);
}
if (value instanceof Object[]) {
Object[] values = (Object[]) value;
String[] result = new String[values.length];
for (int i = 0; i < values.length; i++) {
result[i] = values[i] == null ? "" : values[i].toString();
}
return result;
}
return new String[] { value.toString() };
}
private static String[] iterableToArray(Iterable<?> values) {
java.util.ArrayList<String> result = new java.util.ArrayList<>();
for (Object value : values) {
result.add(value == null ? "" : value.toString());
}
return result.toArray(new String[0]);
}
}
private static final class CapturingResponseWrapper extends HttpServletResponseWrapper {
private int status = SC_OK;
private String redirectLocation = "";
private CapturingResponseWrapper(HttpServletResponse response) {
super(response);
}
@Override
public void sendRedirect(String location) throws IOException {
this.redirectLocation = location;
this.status = SC_FOUND;
}
@Override
public void sendError(int sc) throws IOException {
this.status = sc;
}
@Override
public void sendError(int sc, String msg) throws IOException {
this.status = sc;
}
@Override
public void setStatus(int sc) {
this.status = sc;
super.setStatus(sc);
}
@Override
public int getStatus() {
return status;
}
private int status() {
return status;
}
private String redirectLocation() {
return redirectLocation;
}
}
}
@@ -0,0 +1,192 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.resources;
import java.util.Collections;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.base.util.UtilValidate;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.modernapi.core.ApiResult;
import org.apache.ofbiz.modernapi.core.ModernApiUtil;
import org.apache.ofbiz.webapp.control.LoginWorker;
@Path("/v1")
@Produces(MediaType.APPLICATION_JSON)
public class AuthResource {
@Context private ServletContext servletContext;
@Context private HttpServletRequest request;
@Context private HttpServletResponse response;
@POST
@Path("/login")
@Consumes(MediaType.APPLICATION_JSON)
public Response login(Map<String, Object> payload) {
String traceId = ModernApiUtil.traceId();
Map<String, String[]> parameters = loginParameters(payload);
LoginRequestWrapper loginRequest = new LoginRequestWrapper(request, parameters);
seedRequestContext(loginRequest);
String loginResult = LoginWorker.login(loginRequest, response);
GenericValue userLogin = ModernApiUtil.userLogin(loginRequest);
if (!"success".equalsIgnoreCase(loginResult) || userLogin == null) {
String message = firstMessage(loginRequest, "用户名或密码不正确,或当前账号无权进入 OFBiz 后台。");
return Response.status(Response.Status.UNAUTHORIZED)
.entity(ApiResult.error("LOGIN_FAILED", message, traceId))
.build();
}
return ModernApiUtil.ok(sessionData(loginRequest, userLogin), traceId);
}
@POST
@Path("/logout")
public Response logout() {
String traceId = ModernApiUtil.traceId();
GenericValue userLogin = ModernApiUtil.userLogin(request);
if (userLogin != null) {
seedRequestContext(request);
LoginWorker.logout(request, response);
} else {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("authenticated", false);
data.put("user", null);
return ModernApiUtil.ok(data, traceId);
}
private void seedRequestContext(HttpServletRequest targetRequest) {
targetRequest.setAttribute("delegator", ModernApiUtil.delegator(servletContext));
targetRequest.setAttribute("dispatcher", ModernApiUtil.dispatcher(servletContext));
targetRequest.setAttribute("security", ModernApiUtil.security(servletContext));
}
private Map<String, Object> sessionData(HttpServletRequest loginRequest, GenericValue userLogin) {
Delegator delegator = ModernApiUtil.delegator(servletContext);
Locale locale = loginRequest.getLocale();
Map<String, Object> data = new LinkedHashMap<>();
data.put("authenticated", true);
data.put("user", Map.of(
"userLoginId", userLogin.getString("userLoginId"),
"partyId", userLogin.getString("partyId")));
data.put("locale", locale == null ? "en" : locale.toLanguageTag());
data.put("tenant", delegator == null ? null : delegator.getDelegatorTenantId());
data.put("theme", Map.of(
"name", "modern-element-plus",
"density", "compact",
"navigation", "module-sidebar"));
data.put("permissions", Map.of("source", "OFBiz Security", "resolved", true));
return data;
}
private Map<String, String[]> loginParameters(Map<String, Object> payload) {
Map<String, String[]> parameters = new LinkedHashMap<>();
parameters.put("USERNAME", new String[] { stringValue(payload, "username") });
parameters.put("PASSWORD", new String[] { stringValue(payload, "password") });
parameters.put("TOKEN", new String[] { stringValue(payload, "token") });
parameters.put("JavaScriptEnabled", new String[] { "Y" });
String tenantId = stringValue(payload, "tenantId");
if (UtilValidate.isNotEmpty(tenantId)) {
parameters.put("userTenantId", new String[] { tenantId });
}
return parameters;
}
private String stringValue(Map<String, Object> payload, String name) {
if (payload == null) {
return "";
}
Object value = payload.get(name);
return value == null ? "" : value.toString();
}
private String firstMessage(HttpServletRequest loginRequest, String fallback) {
for (String name : List.of("_ERROR_MESSAGE_", "_EVENT_MESSAGE_")) {
Object value = loginRequest.getAttribute(name);
if (value != null && !value.toString().isBlank()) {
return value.toString();
}
}
for (String name : List.of("_ERROR_MESSAGE_LIST_", "_EVENT_MESSAGE_LIST_")) {
Object value = loginRequest.getAttribute(name);
if (value != null && !value.toString().isBlank()) {
return value.toString();
}
}
return fallback;
}
private static final class LoginRequestWrapper extends HttpServletRequestWrapper {
private final Map<String, String[]> parameters;
private LoginRequestWrapper(HttpServletRequest request, Map<String, String[]> parameters) {
super(request);
this.parameters = parameters;
}
@Override
public String getParameter(String name) {
String[] values = parameters.get(name);
if (values != null && values.length > 0) {
return values[0];
}
return super.getParameter(name);
}
@Override
public Map<String, String[]> getParameterMap() {
Map<String, String[]> merged = new LinkedHashMap<>(super.getParameterMap());
merged.putAll(parameters);
return Collections.unmodifiableMap(merged);
}
@Override
public Enumeration<String> getParameterNames() {
return Collections.enumeration(getParameterMap().keySet());
}
@Override
public String[] getParameterValues(String name) {
String[] values = parameters.get(name);
return values == null ? super.getParameterValues(name) : values;
}
}
}
@@ -0,0 +1,242 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.resources;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.GenericEntityException;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.entity.condition.EntityCondition;
import org.apache.ofbiz.entity.condition.EntityFunction;
import org.apache.ofbiz.entity.condition.EntityOperator;
import org.apache.ofbiz.entity.model.ModelEntity;
import org.apache.ofbiz.entity.model.ModelField;
import org.apache.ofbiz.entity.util.EntityQuery;
import org.apache.ofbiz.modernapi.core.ApiResult;
import org.apache.ofbiz.modernapi.core.ModernApiUtil;
import org.apache.ofbiz.security.Security;
@Path("/v1/entities")
@Produces(MediaType.APPLICATION_JSON)
public class EntityResource {
@Context private ServletContext servletContext;
@Context private HttpServletRequest request;
@GET
@Path("/{entityName}")
public Response list(@PathParam("entityName") String entityName,
@DefaultValue("0") @QueryParam("page") int page,
@DefaultValue("20") @QueryParam("pageSize") int pageSize,
@QueryParam("query") String queryText,
@QueryParam("orderBy") String orderBy) {
String traceId = ModernApiUtil.traceId();
Delegator delegator = ModernApiUtil.delegator(servletContext);
Security security = ModernApiUtil.security(servletContext);
GenericValue userLogin = ModernApiUtil.userLogin(request);
if (delegator == null) {
return ModernApiUtil.error(Response.Status.SERVICE_UNAVAILABLE, "DELEGATOR_UNAVAILABLE", "OFBiz delegator is not available.", traceId);
}
if (userLogin == null) {
return ModernApiUtil.error(Response.Status.UNAUTHORIZED, "AUTH_REQUIRED", "A valid OFBiz userLogin is required to query entities.", traceId);
}
try {
ModelEntity modelEntity = delegator.getModelEntity(entityName);
if (modelEntity == null) {
if (isOptionalEbayEntity(entityName)) {
return ModernApiUtil.ok(optionalEbayRows(entityName, page, pageSize, queryText, orderBy), traceId);
}
return ModernApiUtil.error(Response.Status.NOT_FOUND, "ENTITY_NOT_FOUND", "No entity named " + entityName + " exists.", traceId);
}
if (!ModernApiUtil.hasEntityViewPermission(security, userLogin, modelEntity)) {
if (isOptionalEbayEntity(entityName)) {
return ModernApiUtil.ok(optionalEbayRows(entityName, page, pageSize, queryText, orderBy), traceId);
}
return ModernApiUtil.error(Response.Status.FORBIDDEN, "ENTITY_FORBIDDEN",
"You do not have view permission for " + entityName + ".", traceId);
}
int safePageSize = ModernApiUtil.safePageSize(pageSize);
int safePage = ModernApiUtil.safePage(page);
EntityCondition condition = queryCondition(modelEntity, queryText);
long total = countRows(delegator, entityName, condition);
EntityQuery query = EntityQuery.use(delegator).from(entityName).offset(safePage * safePageSize).limit(safePageSize);
if (condition != null) {
query.where(condition);
}
List<String> safeOrderBy = orderByFields(modelEntity, orderBy);
if (!safeOrderBy.isEmpty()) {
query.orderBy(safeOrderBy);
}
List<Map<String, Object>> rows = new ArrayList<>();
for (GenericValue value : query.queryList()) {
rows.add(ModernApiUtil.publicFields(value));
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("entityName", entityName);
data.put("rows", rows);
data.put("fields", fields(modelEntity));
data.put("page", safePage);
data.put("pageSize", safePageSize);
data.put("query", queryText == null ? "" : queryText.trim());
data.put("orderBy", safeOrderBy);
data.put("total", total);
data.put("hasMore", ModernApiUtil.hasMore(safePage, safePageSize, total));
return ModernApiUtil.ok(data, traceId);
} catch (GenericEntityException | RuntimeException e) {
if (isOptionalEbayEntity(entityName)) {
return ModernApiUtil.ok(optionalEbayRows(entityName, page, pageSize, queryText, orderBy), traceId);
}
return Response.status(Response.Status.BAD_REQUEST)
.entity(ApiResult.error("ENTITY_QUERY_ERROR", e.getMessage(), traceId))
.build();
}
}
private List<Map<String, Object>> fields(ModelEntity modelEntity) {
List<Map<String, Object>> fields = new ArrayList<>();
for (ModelField field : modelEntity.getFieldsUnmodifiable()) {
fields.add(Map.of(
"name", field.getName(),
"type", field.getType(),
"primaryKey", field.getIsPk(),
"required", field.getIsNotNull()));
}
return fields;
}
private Map<String, Object> optionalEbayRows(String entityName, int page, int pageSize, String queryText, String orderBy) {
int safePageSize = ModernApiUtil.safePageSize(pageSize);
int safePage = ModernApiUtil.safePage(page);
List<String> safeOrderBy = orderBy == null || orderBy.isBlank()
? List.of()
: List.of(orderBy.trim());
Map<String, Object> data = new LinkedHashMap<>();
data.put("entityName", entityName);
data.put("rows", List.of());
data.put("fields", optionalEbayFields(entityName));
data.put("page", safePage);
data.put("pageSize", safePageSize);
data.put("query", queryText == null ? "" : queryText.trim());
data.put("orderBy", safeOrderBy);
data.put("total", 0);
data.put("hasMore", false);
data.put("unavailable", true);
data.put("reason", "本地 OFBiz 未启用 eBay/eBay Store 可选实体,按店铺运营空数据处理。");
return data;
}
private List<Map<String, Object>> optionalEbayFields(String entityName) {
List<Map<String, Object>> fields = new ArrayList<>();
for (String fieldName : optionalEbayFieldNames(entityName)) {
fields.add(Map.of(
"name", fieldName,
"type", "id",
"primaryKey", false,
"required", false));
}
return fields;
}
private boolean isOptionalEbayEntity(String entityName) {
return !optionalEbayFieldNames(entityName).isEmpty();
}
private List<String> optionalEbayFieldNames(String entityName) {
if ("EbayProductListing".equals(entityName)) {
return List.of("productListingId", "itemId", "productStoreId", "productId", "statusId", "startDateTime", "endDateTime", "autoRelisting");
}
if ("EbayProductStoreInventory".equals(entityName)) {
return List.of("productStoreId", "facilityId", "productId", "ebayProductId", "availableToPromiseListing", "activeListing", "sold", "successRatio");
}
if ("EbayConfig".equals(entityName)) {
return List.of("productStoreId", "siteId", "compatibilityLevel", "apiServerUrl", "xmlGatewayUri", "webSiteId");
}
if ("EbayShippingMethod".equals(entityName)) {
return List.of("productStoreId", "shipmentMethodName", "methodTypeEnumId", "amount", "carrierPartyId", "shipmentMethodTypeId");
}
if ("EbayProductStorePref".equals(entityName)) {
return List.of("productStoreId", "autoPrefEnumId", "enabled", "condition1", "condition2", "condition3", "autoPrefJobId", "parentPrefCondId");
}
if ("EBayLogMessagesInfo".equals(entityName)) {
return List.of("productStoreId", "logAck", "functionName", "logMessage", "createDatetime");
}
if ("EbayUserBestOffer".equals(entityName)) {
return List.of("productStoreId", "itemId", "bestOfferId", "userId", "contactStatus");
}
return List.of();
}
private long countRows(Delegator delegator, String entityName, EntityCondition condition) throws GenericEntityException {
EntityQuery countQuery = EntityQuery.use(delegator).from(entityName);
if (condition != null) {
countQuery.where(condition);
}
return countQuery.queryCount();
}
private List<String> orderByFields(ModelEntity modelEntity, String orderBy) {
return ModernApiUtil.safeOrderByFields(modelEntity.getAllFieldNames(), modelEntity.getPkFieldNames(), orderBy);
}
private EntityCondition queryCondition(ModelEntity modelEntity, String queryText) {
if (queryText == null || queryText.isBlank()) {
return null;
}
String value = "%" + queryText.trim().toUpperCase(Locale.ROOT) + "%";
List<EntityCondition> conditions = new ArrayList<>();
for (ModelField field : modelEntity.getFieldsUnmodifiable()) {
String name = field.getName();
String type = field.getType();
if (name == null || type == null || !isSearchableTextField(name, type)) {
continue;
}
conditions.add(EntityCondition.makeCondition(EntityFunction.upperField(name), EntityOperator.LIKE, EntityFunction.upper(value)));
}
return conditions.isEmpty() ? null : EntityCondition.makeCondition(conditions, EntityOperator.OR);
}
private boolean isSearchableTextField(String name, String type) {
String lowerName = name.toLowerCase(Locale.ROOT);
String lowerType = type.toLowerCase(Locale.ROOT);
if (lowerType.contains("date") || lowerType.contains("time") || lowerType.contains("currency") || lowerType.contains("amount")
|| lowerType.contains("fixed-point") || lowerType.contains("floating-point") || lowerType.contains("numeric")) {
return false;
}
return lowerType.contains("id") || lowerType.contains("name") || lowerType.contains("description") || lowerType.contains("comment")
|| lowerType.contains("value") || lowerType.contains("varchar") || lowerType.contains("email") || lowerType.contains("url")
|| lowerName.endsWith("id") || lowerName.contains("name") || lowerName.contains("description") || lowerName.contains("title")
|| lowerName.contains("number") || lowerName.contains("code") || lowerName.contains("status");
}
}
@@ -0,0 +1,37 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.resources;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.modernapi.core.ModernApiUtil;
import org.apache.ofbiz.modernapi.core.UiInventoryLoader;
@Path("/v1/inventory")
@Produces(MediaType.APPLICATION_JSON)
public class InventoryResource {
@GET
public Response inventory() {
return ModernApiUtil.ok(UiInventoryLoader.load(), ModernApiUtil.traceId());
}
}
@@ -0,0 +1,167 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.resources;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.GenericEntityException;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.entity.condition.EntityCondition;
import org.apache.ofbiz.entity.condition.EntityFunction;
import org.apache.ofbiz.entity.condition.EntityOperator;
import org.apache.ofbiz.entity.model.ModelEntity;
import org.apache.ofbiz.entity.model.ModelField;
import org.apache.ofbiz.entity.util.EntityQuery;
import org.apache.ofbiz.modernapi.core.ApiResult;
import org.apache.ofbiz.modernapi.core.ModernApiUtil;
import org.apache.ofbiz.security.Security;
@Path("/v1/lookups")
@Produces(MediaType.APPLICATION_JSON)
public class LookupResource {
@Context private ServletContext servletContext;
@Context private HttpServletRequest request;
@GET
@Path("/{lookupId}")
public Response lookup(@PathParam("lookupId") String lookupId,
@DefaultValue("0") @QueryParam("page") int page,
@DefaultValue("20") @QueryParam("pageSize") int pageSize,
@QueryParam("query") String queryText,
@QueryParam("orderBy") String orderBy) {
String traceId = ModernApiUtil.traceId();
Delegator delegator = ModernApiUtil.delegator(servletContext);
Security security = ModernApiUtil.security(servletContext);
GenericValue userLogin = ModernApiUtil.userLogin(request);
if (delegator == null) {
return ModernApiUtil.error(Response.Status.SERVICE_UNAVAILABLE, "DELEGATOR_UNAVAILABLE", "OFBiz delegator is not available.", traceId);
}
if (userLogin == null) {
return ModernApiUtil.error(Response.Status.UNAUTHORIZED, "AUTH_REQUIRED", "A valid OFBiz userLogin is required to run lookups.", traceId);
}
try {
ModelEntity modelEntity = delegator.getModelEntity(lookupId);
if (modelEntity == null) {
return ModernApiUtil.error(Response.Status.NOT_FOUND, "LOOKUP_NOT_FOUND", "No entity lookup named " + lookupId + " exists.", traceId);
}
if (!ModernApiUtil.hasEntityViewPermission(security, userLogin, modelEntity)) {
return ModernApiUtil.error(Response.Status.FORBIDDEN, "LOOKUP_FORBIDDEN",
"You do not have view permission for " + lookupId + ".", traceId);
}
int safePageSize = ModernApiUtil.safePageSize(pageSize);
int safePage = ModernApiUtil.safePage(page);
EntityCondition condition = queryCondition(modelEntity, queryText);
long total = countRows(delegator, lookupId, condition);
EntityQuery query = EntityQuery.use(delegator).from(lookupId).offset(safePage * safePageSize).limit(safePageSize);
if (condition != null) {
query.where(condition);
}
List<String> safeOrderBy = orderByFields(modelEntity, orderBy);
if (!safeOrderBy.isEmpty()) {
query.orderBy(safeOrderBy);
}
List<Map<String, Object>> rows = new ArrayList<>();
for (GenericValue value : query.queryList()) {
rows.add(ModernApiUtil.publicFields(value));
}
List<Map<String, Object>> fields = new ArrayList<>();
for (ModelField field : modelEntity.getFieldsUnmodifiable()) {
fields.add(Map.of(
"name", field.getName(),
"type", field.getType(),
"primaryKey", field.getIsPk(),
"required", field.getIsNotNull()));
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("lookupId", lookupId);
data.put("rows", rows);
data.put("fields", fields);
data.put("page", safePage);
data.put("pageSize", safePageSize);
data.put("query", queryText == null ? "" : queryText.trim());
data.put("orderBy", safeOrderBy);
data.put("total", total);
data.put("hasMore", ModernApiUtil.hasMore(safePage, safePageSize, total));
return ModernApiUtil.ok(data, traceId);
} catch (GenericEntityException | RuntimeException e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(ApiResult.error("LOOKUP_ERROR", e.getMessage(), traceId))
.build();
}
}
private long countRows(Delegator delegator, String entityName, EntityCondition condition) throws GenericEntityException {
EntityQuery countQuery = EntityQuery.use(delegator).from(entityName);
if (condition != null) {
countQuery.where(condition);
}
return countQuery.queryCount();
}
private List<String> orderByFields(ModelEntity modelEntity, String orderBy) {
return ModernApiUtil.safeOrderByFields(modelEntity.getAllFieldNames(), modelEntity.getPkFieldNames(), orderBy);
}
private EntityCondition queryCondition(ModelEntity modelEntity, String queryText) {
if (queryText == null || queryText.isBlank()) {
return null;
}
String value = "%" + queryText.trim().toUpperCase(Locale.ROOT) + "%";
List<EntityCondition> conditions = new ArrayList<>();
for (ModelField field : modelEntity.getFieldsUnmodifiable()) {
String name = field.getName();
String type = field.getType();
if (name == null || type == null || !isSearchableTextField(name, type)) {
continue;
}
conditions.add(EntityCondition.makeCondition(EntityFunction.upperField(name), EntityOperator.LIKE, EntityFunction.upper(value)));
}
return conditions.isEmpty() ? null : EntityCondition.makeCondition(conditions, EntityOperator.OR);
}
private boolean isSearchableTextField(String name, String type) {
String lowerName = name.toLowerCase(Locale.ROOT);
String lowerType = type.toLowerCase(Locale.ROOT);
if (lowerType.contains("date") || lowerType.contains("time") || lowerType.contains("currency") || lowerType.contains("amount")
|| lowerType.contains("fixed-point") || lowerType.contains("floating-point") || lowerType.contains("numeric")) {
return false;
}
return lowerType.contains("id") || lowerType.contains("name") || lowerType.contains("description") || lowerType.contains("comment")
|| lowerType.contains("value") || lowerType.contains("varchar") || lowerType.contains("email") || lowerType.contains("url")
|| lowerName.endsWith("id") || lowerName.contains("name") || lowerName.contains("description") || lowerName.contains("title")
|| lowerName.contains("number") || lowerName.contains("code") || lowerName.contains("status");
}
}
@@ -0,0 +1,92 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.resources;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.base.component.ComponentConfig;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.modernapi.core.ModernApiUtil;
import org.apache.ofbiz.security.Security;
@Path("/v1/navigation")
@Produces(MediaType.APPLICATION_JSON)
public class NavigationResource {
@Context private ServletContext servletContext;
@Context private HttpServletRequest request;
@GET
public Response navigation() {
String traceId = ModernApiUtil.traceId();
Security security = ModernApiUtil.security(servletContext);
GenericValue userLogin = ModernApiUtil.userLogin(request);
List<Map<String, Object>> applications = new ArrayList<>();
for (ComponentConfig.WebappInfo webapp : ComponentConfig.getAllWebappResourceInfos()) {
if (!webapp.getAppBarDisplay()) {
continue;
}
String[] permissions = webapp.getBasePermission();
boolean allowed = Arrays.stream(permissions).allMatch(permission -> ModernApiUtil.hasViewPermission(security, userLogin, permission));
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", webapp.getName());
item.put("title", webapp.getTitle());
item.put("description", webapp.getDescription());
item.put("component", webapp.getComponentConfig().getComponentName());
item.put("mountPoint", webapp.getContextRoot());
item.put("legacyPath", webapp.getContextRoot());
item.put("modernPath", "/modern/app/#/pages/" + webapp.getName() + "__main");
item.put("layout", layoutFor(webapp.getName(), webapp.getContextRoot()));
item.put("permissions", permissions);
item.put("allowed", allowed);
item.put("children", List.of());
applications.add(item);
}
applications.sort(Comparator.comparing(item -> (String) item.getOrDefault("title", "")));
return ModernApiUtil.ok(Map.of("applications", applications), traceId);
}
private String layoutFor(String webappName, String contextRoot) {
String key = (webappName + " " + contextRoot).toLowerCase();
if (key.contains("ecommerce")) {
return "commerce";
}
if (key.contains("webpos")) {
return "pos";
}
if (key.contains("webtools") || key.contains("setup") || key.contains("example")) {
return "system";
}
return "backoffice";
}
}
@@ -0,0 +1,250 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.resources;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.GenericEntityException;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.entity.condition.EntityCondition;
import org.apache.ofbiz.entity.condition.EntityFunction;
import org.apache.ofbiz.entity.condition.EntityOperator;
import org.apache.ofbiz.entity.model.ModelEntity;
import org.apache.ofbiz.entity.model.ModelField;
import org.apache.ofbiz.entity.util.EntityQuery;
import org.apache.ofbiz.modernapi.core.ApiResult;
import org.apache.ofbiz.modernapi.core.ModernApiUtil;
import org.apache.ofbiz.security.Security;
@Path("/v1/options")
@Produces(MediaType.APPLICATION_JSON)
public class OptionResource {
private static final Pattern TEMPLATE_FIELD = Pattern.compile("\\$\\{([^}]+)}");
@Context private ServletContext servletContext;
@Context private HttpServletRequest request;
@GET
@Path("/{entityName}")
public Response entityOptions(@PathParam("entityName") String entityName,
@QueryParam("keyFieldName") String keyFieldName,
@QueryParam("description") String description,
@QueryParam("query") String queryText,
@QueryParam("constraint") List<String> constraints,
@DefaultValue("40") @QueryParam("pageSize") int pageSize) {
String traceId = ModernApiUtil.traceId();
Delegator delegator = ModernApiUtil.delegator(servletContext);
Security security = ModernApiUtil.security(servletContext);
GenericValue userLogin = ModernApiUtil.userLogin(request);
if (delegator == null) {
return ModernApiUtil.error(Response.Status.SERVICE_UNAVAILABLE, "DELEGATOR_UNAVAILABLE", "OFBiz delegator is not available.", traceId);
}
if (userLogin == null) {
return ModernApiUtil.error(Response.Status.UNAUTHORIZED, "AUTH_REQUIRED", "A valid OFBiz userLogin is required to load options.", traceId);
}
try {
ModelEntity modelEntity = delegator.getModelEntity(entityName);
if (modelEntity == null) {
return ModernApiUtil.error(Response.Status.NOT_FOUND, "OPTIONS_ENTITY_NOT_FOUND", "No entity named " + entityName + " exists.", traceId);
}
if (!ModernApiUtil.hasEntityViewPermission(security, userLogin, modelEntity)) {
return ModernApiUtil.error(Response.Status.FORBIDDEN, "OPTIONS_FORBIDDEN",
"You do not have view permission for " + entityName + ".", traceId);
}
String keyField = safeKeyField(modelEntity, keyFieldName);
List<String> descriptionFields = safeDescriptionFields(modelEntity, description);
int safePageSize = ModernApiUtil.safePageSize(pageSize);
List<Map<String, Object>> appliedConstraints = new ArrayList<>();
EntityCondition condition = combinedCondition(modelEntity, queryText, keyField, descriptionFields, constraints, appliedConstraints);
EntityQuery query = EntityQuery.use(delegator).from(entityName).limit(safePageSize);
if (condition != null) {
query.where(condition);
}
List<String> orderBy = new ArrayList<>();
orderBy.add(keyField);
query.orderBy(orderBy);
List<Map<String, Object>> options = new ArrayList<>();
for (GenericValue value : query.queryList()) {
Object rawValue = value.get(keyField);
if (rawValue == null) {
continue;
}
Map<String, Object> option = new LinkedHashMap<>();
option.put("label", optionLabel(value, keyField, descriptionFields, description));
option.put("value", ModernApiUtil.serializableValue(rawValue));
options.add(option);
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("entityName", entityName);
data.put("keyFieldName", keyField);
data.put("descriptionFields", descriptionFields);
data.put("options", options);
data.put("query", queryText == null ? "" : queryText.trim());
data.put("pageSize", safePageSize);
data.put("constraints", appliedConstraints);
data.put("hasMore", options.size() == safePageSize);
return ModernApiUtil.ok(data, traceId);
} catch (GenericEntityException | RuntimeException e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(ApiResult.error("OPTIONS_QUERY_ERROR", e.getMessage(), traceId))
.build();
}
}
private String safeKeyField(ModelEntity modelEntity, String keyFieldName) {
if (keyFieldName != null && !keyFieldName.isBlank() && modelEntity.isField(keyFieldName)) {
return keyFieldName;
}
if (!modelEntity.getPkFieldNames().isEmpty()) {
return modelEntity.getPkFieldNames().get(0);
}
return modelEntity.getFieldsUnmodifiable().get(0).getName();
}
private List<String> safeDescriptionFields(ModelEntity modelEntity, String description) {
List<String> fields = new ArrayList<>();
if (description != null) {
Matcher matcher = TEMPLATE_FIELD.matcher(description);
while (matcher.find()) {
String field = matcher.group(1).trim();
if (modelEntity.isField(field) && !fields.contains(field)) {
fields.add(field);
}
}
}
for (String fallback : List.of("description", "name", "productName", "groupName", "firstName", "lastName")) {
if (fields.size() >= 3) {
break;
}
if (modelEntity.isField(fallback) && !fields.contains(fallback)) {
fields.add(fallback);
}
}
return fields;
}
private String optionLabel(GenericValue value, String keyField, List<String> descriptionFields, String description) {
String label = description == null ? "" : description;
for (String field : descriptionFields) {
Object fieldValue = value.get(field);
if (fieldValue == null) {
continue;
}
label = label.replace("${" + field + "}", String.valueOf(ModernApiUtil.serializableValue(fieldValue)));
}
label = label.replaceAll("\\$\\{[^}]+}", "").trim();
if (!label.isEmpty()) {
return label;
}
for (String field : descriptionFields) {
Object fieldValue = value.get(field);
if (fieldValue != null) {
return String.valueOf(ModernApiUtil.serializableValue(fieldValue));
}
}
Object keyValue = value.get(keyField);
return keyValue == null ? "" : String.valueOf(ModernApiUtil.serializableValue(keyValue));
}
private EntityCondition queryCondition(ModelEntity modelEntity, String queryText, String keyField, List<String> descriptionFields) {
if (queryText == null || queryText.isBlank()) {
return null;
}
String value = "%" + queryText.trim().toUpperCase(Locale.ROOT) + "%";
List<EntityCondition> conditions = new ArrayList<>();
if (isSearchableTextField(modelEntity, keyField)) {
conditions.add(EntityCondition.makeCondition(EntityFunction.upperField(keyField), EntityOperator.LIKE, EntityFunction.upper(value)));
}
for (String field : descriptionFields) {
if (isSearchableTextField(modelEntity, field)) {
conditions.add(EntityCondition.makeCondition(EntityFunction.upperField(field), EntityOperator.LIKE, EntityFunction.upper(value)));
}
}
return conditions.isEmpty() ? null : EntityCondition.makeCondition(conditions, EntityOperator.OR);
}
private EntityCondition combinedCondition(ModelEntity modelEntity, String queryText, String keyField, List<String> descriptionFields,
List<String> constraints, List<Map<String, Object>> appliedConstraints) {
List<EntityCondition> conditions = new ArrayList<>();
EntityCondition textCondition = queryCondition(modelEntity, queryText, keyField, descriptionFields);
if (textCondition != null) {
conditions.add(textCondition);
}
conditions.addAll(constraintConditions(modelEntity, constraints, appliedConstraints));
if (conditions.isEmpty()) {
return null;
}
return conditions.size() == 1 ? conditions.get(0) : EntityCondition.makeCondition(conditions, EntityOperator.AND);
}
private List<EntityCondition> constraintConditions(ModelEntity modelEntity, List<String> constraints, List<Map<String, Object>> appliedConstraints) {
List<EntityCondition> conditions = new ArrayList<>();
if (constraints == null || constraints.isEmpty()) {
return conditions;
}
for (String rawConstraint : constraints) {
if (rawConstraint == null || rawConstraint.isBlank()) {
continue;
}
String[] parts = rawConstraint.split(":", 2);
if (parts.length != 2) {
continue;
}
String fieldName = parts[0].trim();
String value = parts[1].trim();
if (fieldName.isEmpty() || value.isEmpty() || !modelEntity.isField(fieldName)) {
continue;
}
conditions.add(EntityCondition.makeCondition(fieldName, EntityOperator.EQUALS, value));
appliedConstraints.add(Map.of("name", fieldName, "operator", "equals", "value", value));
}
return conditions;
}
private boolean isSearchableTextField(ModelEntity modelEntity, String name) {
ModelField field = modelEntity.getField(name);
if (field == null || field.getType() == null) {
return false;
}
String lowerType = field.getType().toLowerCase(Locale.ROOT);
return lowerType.contains("id") || lowerType.contains("name") || lowerType.contains("description") || lowerType.contains("comment")
|| lowerType.contains("value") || lowerType.contains("varchar") || lowerType.contains("email") || lowerType.contains("url");
}
}
@@ -0,0 +1,65 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.resources;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.modernapi.core.ModernApiUtil;
import org.apache.ofbiz.modernapi.core.UiInventoryLoader;
@Path("/v1/pages")
@Produces(MediaType.APPLICATION_JSON)
public class PageResource {
@GET
@Path("/{pageId}")
public Response pageDefinition(@PathParam("pageId") String pageId) {
String traceId = ModernApiUtil.traceId();
Map<String, Object> page = UiInventoryLoader.findPage(pageId);
if (page.isEmpty()) {
page = fallbackPage(pageId);
}
return ModernApiUtil.ok(page, traceId);
}
private Map<String, Object> fallbackPage(String pageId) {
Map<String, Object> block = new LinkedHashMap<>();
block.put("type", "empty");
block.put("title", "Page definition not generated");
block.put("description", "Run plugins/modern-api/scripts/generate-ui-inventory.mjs to create the route and widget metadata.");
Map<String, Object> page = new LinkedHashMap<>();
page.put("pageId", pageId);
page.put("title", pageId);
page.put("layout", "system");
page.put("blocks", List.of(block));
page.put("actions", List.of());
page.put("permissions", List.of());
page.put("legacy", Map.of("generated", false));
return page;
}
}
@@ -0,0 +1,65 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.resources;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.modernapi.core.ModernApiUtil;
@Path("/v1/session")
@Produces(MediaType.APPLICATION_JSON)
public class SessionResource {
@Context private ServletContext servletContext;
@Context private HttpServletRequest request;
@GET
public Response currentSession() {
String traceId = ModernApiUtil.traceId();
Delegator delegator = ModernApiUtil.delegator(servletContext);
GenericValue userLogin = ModernApiUtil.userLogin(request);
Locale locale = request.getLocale();
Map<String, Object> data = new LinkedHashMap<>();
data.put("authenticated", userLogin != null);
data.put("user", userLogin == null ? null : Map.of(
"userLoginId", userLogin.getString("userLoginId"),
"partyId", userLogin.getString("partyId")));
data.put("locale", locale == null ? "en" : locale.toLanguageTag());
data.put("tenant", delegator == null ? null : delegator.getDelegatorTenantId());
data.put("theme", Map.of(
"name", "modern-element-plus",
"density", "compact",
"navigation", "module-sidebar"));
data.put("permissions", Map.of("source", "OFBiz Security", "resolved", userLogin != null));
return ModernApiUtil.ok(data, traceId);
}
}
@@ -0,0 +1,45 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.resources;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.modernapi.core.ApiResult;
import org.apache.ofbiz.modernapi.core.ModernApiUtil;
@Path("/v1/uploads")
@Produces(MediaType.APPLICATION_JSON)
public class UploadResource {
@POST
@Path("/{uploadId}")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response upload(@PathParam("uploadId") String uploadId) {
String traceId = ModernApiUtil.traceId();
return Response.status(Response.Status.NOT_IMPLEMENTED)
.entity(ApiResult.error("UPLOAD_CONTRACT_ONLY",
"Upload endpoint " + uploadId + " is reserved for OFBiz secure upload policy integration.", traceId))
.build();
}
}
@@ -0,0 +1,98 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.core;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.concurrent.atomic.AtomicInteger;
import javax.servlet.FilterChain;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.security.Security;
import org.apache.ofbiz.service.LocalDispatcher;
import org.junit.Test;
public class ModernApiAuthFilterTests {
@Test
public void bridgesSecuredLoginCookieWhenApiSessionHasNoUserLogin() throws Exception {
ServletContext servletContext = mock(ServletContext.class);
Delegator delegator = mock(Delegator.class);
LocalDispatcher dispatcher = mock(LocalDispatcher.class);
Security security = mock(Security.class);
when(servletContext.getAttribute("delegator")).thenReturn(delegator);
when(servletContext.getAttribute("dispatcher")).thenReturn(dispatcher);
when(servletContext.getAttribute("security")).thenReturn(security);
HttpSession session = mock(HttpSession.class);
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
FilterChain chain = mock(FilterChain.class);
when(request.getServletContext()).thenReturn(servletContext);
when(request.getSession(false)).thenReturn(session);
AtomicInteger bridgeCalls = new AtomicInteger();
ModernApiAuthFilter filter = new ModernApiAuthFilter((req, resp) -> {
bridgeCalls.incrementAndGet();
return "success";
});
filter.doFilter(request, response, chain);
verify(request).setAttribute("delegator", delegator);
verify(request).setAttribute("dispatcher", dispatcher);
verify(request).setAttribute("security", security);
assertEquals(1, bridgeCalls.get());
verify(chain).doFilter(request, response);
}
@Test
public void skipsBridgeWhenApiSessionAlreadyHasUserLogin() throws Exception {
ServletContext servletContext = mock(ServletContext.class);
HttpSession session = mock(HttpSession.class);
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
FilterChain chain = mock(FilterChain.class);
GenericValue userLogin = new GenericValue();
when(request.getServletContext()).thenReturn(servletContext);
when(request.getSession(false)).thenReturn(session);
when(session.getAttribute("userLogin")).thenReturn(userLogin);
AtomicInteger bridgeCalls = new AtomicInteger();
ModernApiAuthFilter filter = new ModernApiAuthFilter((req, resp) -> {
bridgeCalls.incrementAndGet();
return "success";
});
filter.doFilter(request, response, chain);
assertEquals(0, bridgeCalls.get());
verify(request, never()).setAttribute("delegator", null);
verify(chain).doFilter(request, response);
}
}
@@ -0,0 +1,414 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.core;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import javax.ws.rs.core.Response;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.entity.model.ModelEntity;
import org.apache.ofbiz.modernapi.resources.ActionResource;
import org.apache.ofbiz.modernapi.resources.EntityResource;
import org.apache.ofbiz.modernapi.resources.LookupResource;
import org.apache.ofbiz.modernapi.resources.OptionResource;
import org.apache.ofbiz.modernapi.resources.PageResource;
import org.apache.ofbiz.modernapi.resources.SessionResource;
import org.apache.ofbiz.modernapi.resources.UploadResource;
import org.apache.ofbiz.security.Security;
import org.junit.Test;
public class ModernApiContractTests {
@Test
public void clampsPaginationToModernApiBounds() {
Map<String, Object> data = ModernApiUtil.pagination(-4, 250, 0);
assertEquals(0, data.get("page"));
assertEquals(100, data.get("pageSize"));
assertEquals(0L, data.get("total"));
assertEquals(false, data.get("hasMore"));
}
@Test
public void reportsHasMoreWhenNextPageContainsRows() {
Map<String, Object> data = ModernApiUtil.pagination(1, 20, 45);
assertEquals(1, data.get("page"));
assertEquals(20, data.get("pageSize"));
assertEquals(45L, data.get("total"));
assertEquals(true, data.get("hasMore"));
}
@Test
public void keepsOnlyKnownOrderByFieldsAndFallsBackToPrimaryKey() {
List<String> orderBy = ModernApiUtil.safeOrderByFields(
List.of("orderId", "statusId", "createdStamp"),
List.of("orderId"),
"statusId DESC, missingField ASC, -createdStamp");
assertEquals(List.of("statusId DESC", "createdStamp DESC"), orderBy);
List<String> fallback = ModernApiUtil.safeOrderByFields(
List.of("orderId", "statusId"),
List.of("orderId"),
"missingField DESC");
assertEquals(List.of("orderId"), fallback);
}
@Test
public void sessionEndpointReturnsUnauthenticatedContractInsteadOfAuthError() throws Exception {
ServletContext servletContext = mock(ServletContext.class);
HttpServletRequest request = mock(HttpServletRequest.class);
Delegator delegator = mock(Delegator.class);
when(servletContext.getAttribute("delegator")).thenReturn(delegator);
when(delegator.getDelegatorTenantId()).thenReturn("default");
when(request.getSession(false)).thenReturn(null);
when(request.getLocale()).thenReturn(java.util.Locale.US);
SessionResource resource = new SessionResource();
inject(resource, "servletContext", servletContext);
inject(resource, "request", request);
Response response = resource.currentSession();
Map<String, Object> entity = responseEntity(response);
Map<String, Object> data = data(entity);
assertEquals(200, response.getStatus());
assertTrue((Boolean) entity.get("ok"));
assertFalse((Boolean) data.get("authenticated"));
assertEquals("en-US", data.get("locale"));
assertEquals("default", data.get("tenant"));
}
@Test
public void serviceActionsRequireLoginBeforeExecution() throws Exception {
ServletContext servletContext = mock(ServletContext.class);
HttpServletRequest request = mock(HttpServletRequest.class);
HttpSession session = mock(HttpSession.class);
when(request.getSession(false)).thenReturn(session);
when(session.getAttribute("userLogin")).thenReturn(null);
ActionResource resource = new ActionResource();
inject(resource, "servletContext", servletContext);
inject(resource, "request", request);
Response response = resource.runAction("createOrder", Map.of("orderId", "1000"));
Map<String, Object> entity = responseEntity(response);
assertEquals(401, response.getStatus());
assertFalse((Boolean) entity.get("ok"));
assertEquals("AUTH_REQUIRED", firstErrorCode(entity));
}
@Test
public void protectedPagesRequireLoginBeforeReturningDefinitions() throws Exception {
seedInventory(
Map.of("accounting__main", pageDefinition("accounting__main", List.of("ACCOUNTING"))),
Map.of());
ServletContext servletContext = mock(ServletContext.class);
PageResource resource = new PageResource();
inject(resource, "servletContext", servletContext);
inject(resource, "request", requestWithoutLogin());
Response response = resource.pageDefinition("accounting__main");
assertAuthRequired(response);
}
@Test
public void protectedPagesDenyUsersWithoutBasePermission() throws Exception {
seedInventory(
Map.of("accounting__main", pageDefinition("accounting__main", List.of("ACCOUNTING"))),
Map.of());
ServletContext servletContext = mock(ServletContext.class);
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
when(servletContext.getAttribute("security")).thenReturn(security);
PageResource resource = new PageResource();
inject(resource, "servletContext", servletContext);
inject(resource, "request", requestWithLogin(userLogin));
Response response = resource.pageDefinition("accounting__main");
assertForbidden(response, "PAGE_FORBIDDEN");
}
@Test
public void protectedPagesAllowUsersWithBasePermission() throws Exception {
seedInventory(
Map.of("accounting__main", pageDefinition("accounting__main", List.of("ACCOUNTING"))),
Map.of());
ServletContext servletContext = mock(ServletContext.class);
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
when(servletContext.getAttribute("security")).thenReturn(security);
when(security.hasEntityPermission("ACCOUNTING", "_VIEW", userLogin)).thenReturn(true);
PageResource resource = new PageResource();
inject(resource, "servletContext", servletContext);
inject(resource, "request", requestWithLogin(userLogin));
Response response = resource.pageDefinition("accounting__main");
Map<String, Object> entity = responseEntity(response);
Map<String, Object> data = data(entity);
assertEquals(200, response.getStatus());
assertTrue((Boolean) entity.get("ok"));
assertEquals("accounting__main", data.get("pageId"));
}
@Test
public void navigationActionsRequireLoginBeforeReturningTargets() throws Exception {
seedInventory(
Map.of("accounting__main", pageDefinition("accounting__main", List.of("ACCOUNTING"))),
Map.of("accounting__main", navigationAction("accounting__main", "accounting__main")));
ServletContext servletContext = mock(ServletContext.class);
ActionResource resource = new ActionResource();
inject(resource, "servletContext", servletContext);
inject(resource, "request", requestWithoutLogin());
Response response = resource.runAction("accounting__main", Map.of());
assertAuthRequired(response);
}
@Test
public void navigationActionsDenyUsersWithoutTargetPagePermission() throws Exception {
seedInventory(
Map.of("accounting__main", pageDefinition("accounting__main", List.of("ACCOUNTING"))),
Map.of("accounting__main", navigationAction("accounting__main", "accounting__main")));
ServletContext servletContext = mock(ServletContext.class);
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
when(servletContext.getAttribute("security")).thenReturn(security);
ActionResource resource = new ActionResource();
inject(resource, "servletContext", servletContext);
inject(resource, "request", requestWithLogin(userLogin));
Response response = resource.runAction("accounting__main", Map.of());
assertForbidden(response, "ACTION_FORBIDDEN");
}
@Test
public void navigationActionsAllowUsersWithTargetPagePermission() throws Exception {
seedInventory(
Map.of("accounting__main", pageDefinition("accounting__main", List.of("ACCOUNTING"))),
Map.of("accounting__main", navigationAction("accounting__main", "accounting__main")));
ServletContext servletContext = mock(ServletContext.class);
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
when(servletContext.getAttribute("security")).thenReturn(security);
when(security.hasEntityPermission("ACCOUNTING", "_VIEW", userLogin)).thenReturn(true);
ActionResource resource = new ActionResource();
inject(resource, "servletContext", servletContext);
inject(resource, "request", requestWithLogin(userLogin));
Response response = resource.runAction("accounting__main", Map.of());
Map<String, Object> entity = responseEntity(response);
Map<String, Object> data = data(entity);
assertEquals(200, response.getStatus());
assertTrue((Boolean) entity.get("ok"));
assertEquals("navigation", data.get("actionType"));
assertEquals("accounting__main", data.get("navigationPageId"));
}
@Test
public void uploadsRemainExplicitContractOnlyUntilPolicyIsImplemented() {
UploadResource resource = new UploadResource();
Response response = resource.upload("partyContent");
Map<String, Object> entity = responseEntity(response);
assertEquals(501, response.getStatus());
assertFalse((Boolean) entity.get("ok"));
assertEquals("UPLOAD_CONTRACT_ONLY", firstErrorCode(entity));
}
@Test
public void entityLookupAndOptionReadsRequireLogin() throws Exception {
ServletContext servletContext = mock(ServletContext.class);
HttpServletRequest request = requestWithoutLogin();
Delegator delegator = mock(Delegator.class);
when(servletContext.getAttribute("delegator")).thenReturn(delegator);
EntityResource entityResource = new EntityResource();
inject(entityResource, "servletContext", servletContext);
inject(entityResource, "request", request);
assertAuthRequired(entityResource.list("OrderHeader", 0, 20, "", ""));
LookupResource lookupResource = new LookupResource();
inject(lookupResource, "servletContext", servletContext);
inject(lookupResource, "request", request);
assertAuthRequired(lookupResource.lookup("Party", 0, 20, "", ""));
OptionResource optionResource = new OptionResource();
inject(optionResource, "servletContext", servletContext);
inject(optionResource, "request", request);
assertAuthRequired(optionResource.entityOptions("StatusItem", null, null, "", List.of(), 40));
}
@Test
public void entityLookupAndOptionReadsDenyUsersWithoutEntityPermission() throws Exception {
ServletContext servletContext = mock(ServletContext.class);
HttpServletRequest request = requestWithLogin(new GenericValue());
Delegator delegator = mock(Delegator.class);
Security security = mock(Security.class);
ModelEntity modelEntity = mock(ModelEntity.class);
when(servletContext.getAttribute("delegator")).thenReturn(delegator);
when(servletContext.getAttribute("security")).thenReturn(security);
when(delegator.getModelEntity("OrderHeader")).thenReturn(modelEntity);
when(delegator.getModelEntity("Party")).thenReturn(modelEntity);
when(delegator.getModelEntity("StatusItem")).thenReturn(modelEntity);
when(modelEntity.getPackageName()).thenReturn("org.apache.ofbiz.order.order");
when(modelEntity.getEntityName()).thenReturn("OrderHeader");
EntityResource entityResource = new EntityResource();
inject(entityResource, "servletContext", servletContext);
inject(entityResource, "request", request);
assertForbidden(entityResource.list("OrderHeader", 0, 20, "", ""));
LookupResource lookupResource = new LookupResource();
inject(lookupResource, "servletContext", servletContext);
inject(lookupResource, "request", request);
assertForbidden(lookupResource.lookup("Party", 0, 20, "", ""));
OptionResource optionResource = new OptionResource();
inject(optionResource, "servletContext", servletContext);
inject(optionResource, "request", request);
assertForbidden(optionResource.entityOptions("StatusItem", null, null, "", List.of(), 40));
}
@SuppressWarnings("unchecked")
private Map<String, Object> responseEntity(Response response) {
return (Map<String, Object>) response.getEntity();
}
@SuppressWarnings("unchecked")
private Map<String, Object> data(Map<String, Object> entity) {
return (Map<String, Object>) entity.get("data");
}
@SuppressWarnings("unchecked")
private String firstErrorCode(Map<String, Object> entity) {
List<Map<String, Object>> errors = (List<Map<String, Object>>) entity.get("errors");
return (String) errors.get(0).get("code");
}
private void assertAuthRequired(Response response) {
Map<String, Object> entity = responseEntity(response);
assertEquals(401, response.getStatus());
assertFalse((Boolean) entity.get("ok"));
assertEquals("AUTH_REQUIRED", firstErrorCode(entity));
}
private void assertForbidden(Response response) {
assertForbidden(response, null);
}
private void assertForbidden(Response response, String code) {
Map<String, Object> entity = responseEntity(response);
assertEquals(403, response.getStatus());
assertFalse((Boolean) entity.get("ok"));
if (code != null) {
assertEquals(code, firstErrorCode(entity));
}
}
private HttpServletRequest requestWithoutLogin() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getSession(false)).thenReturn(null);
return request;
}
private HttpServletRequest requestWithLogin(GenericValue userLogin) {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpSession session = mock(HttpSession.class);
when(request.getSession(false)).thenReturn(session);
when(session.getAttribute("userLogin")).thenReturn(userLogin);
return request;
}
private void inject(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
private void seedInventory(Map<String, Object> pageDefinitions, Map<String, Object> actionDefinitions) throws Exception {
Map<String, Object> inventory = new LinkedHashMap<>();
inventory.put("pageDefinitions", pageDefinitions);
inventory.put("actionDefinitions", actionDefinitions);
setInventoryField("cachedInventory", inventory);
setInventoryField("cachedInventoryPath", null);
setInventoryField("cachedInventoryLastModified", -1L);
}
private void setInventoryField(String name, Object value) throws Exception {
Field field = UiInventoryLoader.class.getDeclaredField(name);
field.setAccessible(true);
field.set(null, value);
}
private Map<String, Object> pageDefinition(String pageId, List<String> permissions) {
Map<String, Object> page = new LinkedHashMap<>();
page.put("pageId", pageId);
page.put("title", pageId);
page.put("layout", "backoffice");
page.put("blocks", List.of());
page.put("actions", List.of());
page.put("permissions", permissions);
page.put("legacy", Map.of("generated", true));
return page;
}
private Map<String, Object> navigationAction(String actionId, String pageId) {
Map<String, Object> target = new LinkedHashMap<>();
target.put("name", "success");
target.put("type", "view");
target.put("value", pageId);
target.put("pageId", pageId);
target.put("modernPath", "/modern/app/#/pages/" + pageId);
Map<String, Object> action = new LinkedHashMap<>();
action.put("actionId", actionId);
action.put("label", actionId);
action.put("source", "controller");
action.put("eventType", "");
action.put("eventInvoke", "");
action.put("eventPath", "");
action.put("serviceName", "");
action.put("auth", true);
action.put("responses", List.of(target));
return action;
}
}
@@ -0,0 +1,120 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.modernapi.core;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.apache.ofbiz.entity.GenericValue;
import org.apache.ofbiz.entity.model.ModelEntity;
import org.apache.ofbiz.security.Security;
import org.junit.Test;
public class ModernApiUtilPermissionTests {
@Test
public void mapsOrderEntityToOrderManagerViewPermission() {
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
ModelEntity modelEntity = modelEntity("OrderHeader", "org.apache.ofbiz.order.order");
when(security.hasEntityPermission("ORDERMGR", "_VIEW", userLogin)).thenReturn(true);
assertTrue(ModernApiUtil.hasEntityViewPermission(security, userLogin, modelEntity));
verify(security).hasEntityPermission("ORDERMGR", "_VIEW", userLogin);
}
@Test
public void allowsCommonReferenceOptionsWhenUserHasBusinessViewPermission() {
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
ModelEntity modelEntity = modelEntity("StatusItem", "org.apache.ofbiz.common.status");
when(security.hasEntityPermission("ORDERMGR", "_VIEW", userLogin)).thenReturn(true);
assertTrue(ModernApiUtil.hasEntityViewPermission(security, userLogin, modelEntity));
verify(security).hasEntityPermission("ORDERMGR", "_VIEW", userLogin);
}
@Test
public void allowsEntityDataAdminToViewAnyEntity() {
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
ModelEntity modelEntity = modelEntity("UnmappedThing", "org.example.custom");
when(security.hasEntityPermission("ENTITY_DATA", "_ADMIN", userLogin)).thenReturn(true);
assertTrue(ModernApiUtil.hasEntityViewPermission(security, userLogin, modelEntity));
verify(security).hasEntityPermission("ENTITY_DATA", "_ADMIN", userLogin);
}
@Test
public void allowsBusinessAdminToViewMappedEntity() {
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
ModelEntity modelEntity = modelEntity("OrderHeader", "org.apache.ofbiz.order.order");
when(security.hasEntityPermission("ORDERMGR", "_ADMIN", userLogin)).thenReturn(true);
assertTrue(ModernApiUtil.hasEntityViewPermission(security, userLogin, modelEntity));
verify(security).hasEntityPermission("ORDERMGR", "_ADMIN", userLogin);
}
@Test
public void mapsMarketingEntityToMarketingViewPermission() {
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
ModelEntity modelEntity = modelEntity("MarketingCampaign", "org.apache.ofbiz.marketing.campaign");
when(security.hasEntityPermission("MARKETING", "_VIEW", userLogin)).thenReturn(true);
assertTrue(ModernApiUtil.hasEntityViewPermission(security, userLogin, modelEntity));
verify(security).hasEntityPermission("MARKETING", "_VIEW", userLogin);
}
@Test
public void mapsWebsiteEntityToContentManagerViewPermission() {
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
ModelEntity modelEntity = modelEntity("WebSite", "org.apache.ofbiz.webapp.website");
when(security.hasEntityPermission("CONTENTMGR", "_VIEW", userLogin)).thenReturn(true);
assertTrue(ModernApiUtil.hasEntityViewPermission(security, userLogin, modelEntity));
verify(security).hasEntityPermission("CONTENTMGR", "_VIEW", userLogin);
}
@Test
public void deniesUnmappedEntityWithoutExplicitPermission() {
Security security = mock(Security.class);
GenericValue userLogin = new GenericValue();
ModelEntity modelEntity = modelEntity("UnmappedThing", "org.example.custom");
assertFalse(ModernApiUtil.hasEntityViewPermission(security, userLogin, modelEntity));
}
private ModelEntity modelEntity(String entityName, String packageName) {
ModelEntity modelEntity = new ModelEntity();
modelEntity.setEntityName(entityName);
modelEntity.setPackageName(packageName);
return modelEntity;
}
}
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<site-conf xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://ofbiz.apache.org/Site-Conf" xsi:schemaLocation="http://ofbiz.apache.org/Site-Conf http://ofbiz.apache.org/dtds/site-conf.xsd">
<include location="component://common/webcommon/WEB-INF/common-controller.xml"/>
<description>Modern API control configuration for login and logout session events.</description>
<request-map uri="main">
<security https="false" auth="false"/>
<response name="success" type="none"/>
</request-map>
</site-conf>
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<web-app version="4.0" xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd">
<display-name>Apache OFBiz - Modern API</display-name>
<description>Modern API bridge for the Vue 3 + Element Plus OFBiz frontend.</description>
<context-param>
<param-name>localDispatcherName</param-name>
<param-value>modern-api</param-value>
</context-param>
<context-param>
<param-name>entityDelegatorName</param-name>
<param-value>default</param-value>
</context-param>
<filter>
<filter-name>Modern API Auth Filter</filter-name>
<filter-class>org.apache.ofbiz.modernapi.core.ModernApiAuthFilter</filter-class>
</filter>
<filter>
<filter-name>Jersey Modern API Filter</filter-name>
<filter-class>org.glassfish.jersey.servlet.ServletContainer</filter-class>
<init-param>
<param-name>javax.ws.rs.Application</param-name>
<param-value>org.apache.ofbiz.modernapi.core.ModernApiConfig</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>Modern API Auth Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter-mapping>
<filter-name>Jersey Modern API Filter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<listener>
<listener-class>org.apache.ofbiz.modernapi.core.ModernApiContextListener</listener-class>
</listener>
</web-app>