Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
face3bbbd2 | ||
|
|
75a4e6b168 | ||
|
|
3ed107112c | ||
|
|
06857eb389 | ||
|
|
298a91cbf8 | ||
|
|
9db6d4bb59 | ||
|
|
9f581996d0 | ||
|
|
c9d324acd4 | ||
|
|
cd2128f78f | ||
|
|
3d336c3dfe
|
||
|
|
73411423df | ||
|
|
cccd0ac0ab
|
||
|
|
2681447b07 | ||
|
|
55a28c7031
|
||
|
|
2491669f23 | ||
|
|
36f1b939b4
|
||
|
|
77d63135af
|
||
|
|
603e518a34 | ||
|
|
d1eb78c1df
|
||
|
|
2810e00e9c | ||
|
|
89a95737a5
|
||
|
|
86b8dbf13d | ||
|
|
4471a13e78
|
||
|
|
c39d78901c | ||
|
|
1e25fe54d2
|
||
|
|
0fe99db48d |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -6,6 +6,12 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
|
||||
### Changed
|
||||
|
||||
- EDI home is business-only: System, Dead letters, Certificates expiring and Setup status move to `EdiHome.ADMIN_WIDGET_NAMES` for the host's Admin home (gated to `edi.admin.hasAccess`, full width); every home widget now requires `edi.hasAccess` and is hidden without it. A new "Inbound documents are delayed" alert links to the delayed inbound documents once the oldest is older than `inboundDelayedAfterMinutes` (default 60). Setup counts that fail show "Unavailable" and are logged instead of -1.
|
||||
|
||||
- UI API extension edi 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, and retained deprecated EdiErrorV1 schema.
|
||||
|
||||
- Derive qbit metadata version from the Maven artifact version, keeping release metadata and help bundle versions in sync.
|
||||
|
||||
- Require QQQ 4.1.0-RC.7 for core help-bundle registration and coverage validation.
|
||||
|
||||
- X12 routing and sending honor identity `isActive`, matching EDIFACT.
|
||||
|
||||
@@ -95,8 +95,8 @@ transports/guides and local certificates/identities.
|
||||
|
||||
## UI API
|
||||
|
||||
With optional Javalin present, the producer registers the `edi` extension, version `1.0.0`,
|
||||
under `/qqq/v1/qbit/edi`. All routes are endpoint specs and pass strict UI-route auditing.
|
||||
With optional Javalin present, the producer registers the `edi` extension, version `1.1.0`,
|
||||
under `/qqq/v1/qbit/edi`, requiring QQQ 4.1.0-RC.15 (UI API 1.6.0). All routes are endpoint specs and pass strict UI-route auditing.
|
||||
|
||||
| Method | Path after `/qqq/v1/qbit/edi` | Result |
|
||||
|---|---|---|
|
||||
@@ -133,15 +133,15 @@ The mapper saves `edi_map_rule` through the **core table API**, using its normal
|
||||
`edi.map_rule.insert|edit|delete` permissions and record locks. There are no custom rule-write routes.
|
||||
The Next mapper page is separate phase-f work.
|
||||
|
||||
The generated contract is `src/main/resources/openapi/qqq-v1/edi.yaml`; all component names are
|
||||
`Edi...V1`. Regenerate intentionally with:
|
||||
The generated contract is `src/main/resources/openapi/qqq-v1/edi.yaml`; extension component names are
|
||||
`Edi...V1`, alongside core `BasicErrorResponseV1`. `EdiErrorV1` remains deprecated for compatibility. Regenerate with the locally installed migration build:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -B '-Dtest=EdiApiContractTest' '-Dedi.openapi.update=true' test
|
||||
./mvnw.cmd -o -B '-Dqqq.snapshot.version=4.1.99-SNAPSHOT' '-Dtest=EdiApiContractTest' '-Dedi.openapi.update=true' test
|
||||
```
|
||||
|
||||
RC.6 provides `UiApiContract.check`, used to compare generated and checked-in YAML and run
|
||||
`OpenApiCompatibilityChecker` against this initial fragment baseline. The extension also appears
|
||||
Core provides `UiApiContract.check`, used to compare generated and checked-in YAML, validate
|
||||
operation IDs and references, and run `OpenApiCompatibilityChecker` against the committed fragment baseline. The extension also appears
|
||||
in the host's `/qqq/v1/openapi.yaml` and `/qqq/v1/version`.
|
||||
|
||||
## Inbound operation
|
||||
@@ -200,8 +200,9 @@ Everything else is host or runtime data, and every table explains itself when em
|
||||
4. Agreement documents: one per transaction set and direction, with the guide and optionally a map.
|
||||
5. Transport (AS2, SFTP, HTTPS), linked on the agreement. Certificates only for AS2 signing and encryption.
|
||||
|
||||
The **Setup status** section on the home shows each step, whether it is done (counting only the
|
||||
active rows the runtime uses) and the next step.
|
||||
The **Setup status** widget (`edi_home_setup`, for the host's Admin home) shows each step, whether
|
||||
it is done (counting only the active rows the runtime uses) and the next step. A count that fails
|
||||
shows "Unavailable" and is logged.
|
||||
|
||||
Maps start empty. The **Install sample maps** action on Maps adds the 32 bundled synthetic examples
|
||||
as draft maps with no partner and no agreement document, by name and never over an existing map. Point
|
||||
@@ -229,7 +230,14 @@ Full table permissions include `edi.partner.read|insert|edit|delete`, `edi.map.*
|
||||
| Scheduled workers / ESB enqueue | `edi.admin` |
|
||||
|
||||
Partner locks use `edi_partner` with `edi.admin` all-access. Home queries explicitly enforce
|
||||
the viewing user's permissions. Processes are table/record actions, never navigation entries.
|
||||
the viewing user's permissions.
|
||||
|
||||
The EDI home shows business work only. Its widgets (`EdiHome.HOME_WIDGET_NAMES`) need
|
||||
`edi.hasAccess` and are hidden without it. "Inbound documents delayed" appears only when the oldest
|
||||
unprocessed inbound document is older than `inboundDelayedAfterMinutes` (default 60), with a link to
|
||||
those documents. System widgets (`EdiHome.ADMIN_WIDGET_NAMES`: System, Dead letters, Certificates
|
||||
expiring, Setup status) are not on the EDI home; they need `edi.admin.hasAccess`, are hidden without
|
||||
it, and a host places them on its Admin home. Processes are table/record actions, never navigation entries.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+3
-1
@@ -5,5 +5,7 @@
|
||||
- No schema change and no Liquibase include.
|
||||
- Behavior change: an inactive identity (`isActive` false) now stops X12 routing and sending, as it already did for EDIFACT. Inbound documents from or to it are rejected as an unknown partner, and outbound requests for its agreements wait until it is active. Identities default to active, so only identities someone switched off are affected.
|
||||
- Hosts that already call `EdiRuntime.initialize()` at startup keep getting the 41 built-in guides and code lists. A host that never called it will now see "Needs setup" for Guides and code lists on the home.
|
||||
- New home section "Setup status" (widget `edi_home_setup`).
|
||||
- New widget "Setup status" (`edi_home_setup`).
|
||||
- Behavior change: the EDI home shows business widgets only (`EdiHome.HOME_WIDGET_NAMES`), each now requiring `edi.hasAccess` (hidden without it). `edi_home_system`, `edi_home_dead`, `edi_home_certificates` and `edi_home_setup` left the EDI home: they are listed in `EdiHome.ADMIN_WIDGET_NAMES`, require `edi.admin.hasAccess` (hidden without it), and the host places them on its Admin home.
|
||||
- New business widget `edi_home_delayed` ("Inbound documents are delayed"), shown only when the oldest unprocessed inbound document is older than the new config value `inboundDelayedAfterMinutes` (default 60).
|
||||
- New table action "Install sample maps" on Maps, permission `edi.admin`. It is never run automatically and only adds draft maps whose name does not exist yet.
|
||||
@@ -126,7 +126,36 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-edi.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-edi.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<!-- the artifact version, for the help bundle version test -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -213,7 +242,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -89,6 +89,7 @@ public class EdiQBitConfig implements QBitConfig
|
||||
private String appLabel = "EDI";
|
||||
private String parentAppName;
|
||||
private String publicBaseUrl;
|
||||
private Integer inboundDelayedAfterMinutes = 60;
|
||||
|
||||
|
||||
|
||||
@@ -114,6 +115,10 @@ public class EdiQBitConfig implements QBitConfig
|
||||
{
|
||||
errors.add("secretsResolver and partnerLocks are required");
|
||||
}
|
||||
if(inboundDelayedAfterMinutes == null || inboundDelayedAfterMinutes < 1)
|
||||
{
|
||||
errors.add("inboundDelayedAfterMinutes must be at least 1");
|
||||
}
|
||||
if(appName == null || appName.isBlank())
|
||||
{
|
||||
errors.add("appName is required");
|
||||
@@ -390,4 +395,22 @@ public class EdiQBitConfig implements QBitConfig
|
||||
publicBaseUrl = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** How old (in minutes) the oldest unprocessed inbound document may get before
|
||||
** the EDI home says "Inbound documents are delayed". Default 60.
|
||||
*******************************************************************************/
|
||||
public Integer getInboundDelayedAfterMinutes()
|
||||
{
|
||||
return (inboundDelayedAfterMinutes);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the inbound delay threshold in minutes.
|
||||
*******************************************************************************/
|
||||
public EdiQBitConfig withInboundDelayedAfterMinutes(Integer value)
|
||||
{
|
||||
inboundDelayedAfterMinutes = value;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -37,13 +40,41 @@ public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-edi";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private EdiQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = EdiQBitProducer.class.getClassLoader().getResourceAsStream("qbit-edi.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw new IllegalStateException("Missing qbit-edi.properties; build with Maven to supply the artifact version");
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw new IllegalStateException("Missing or unfiltered version in qbit-edi.properties; build with Maven");
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw new IllegalStateException("Could not read the artifact version from qbit-edi.properties", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent configuration setter.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -64,6 +64,7 @@ public final class EdiRuntime
|
||||
private final Ack999Builder ack999 = new Ack999Builder();
|
||||
private final TA1Builder ta1 = new TA1Builder();
|
||||
private final InboundAckReader acknowledgements = new InboundAckReader();
|
||||
private final com.kingsrook.qbits.edi.flow.OutboundSourceTables outboundSources = new com.kingsrook.qbits.edi.flow.OutboundSourceTables();
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct services without resolving keys until content is stored or opened.
|
||||
@@ -95,6 +96,22 @@ public final class EdiRuntime
|
||||
return (Objects.requireNonNull(INSTANCES.get(QContext.getQInstance()), "EDI runtime has not been installed"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services for the current QQQ context, or null when none are installed.
|
||||
*******************************************************************************/
|
||||
public static EdiRuntime find()
|
||||
{
|
||||
return (QContext.getQInstance() == null ? null : INSTANCES.get(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cached source tables that have active RECORD_CHANGE outbound definitions.
|
||||
*******************************************************************************/
|
||||
public com.kingsrook.qbits.edi.flow.OutboundSourceTables outboundSources()
|
||||
{
|
||||
return (outboundSources);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Call at host startup after DDL, with the service session established.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
@@ -44,7 +45,7 @@ import com.kingsrook.qqq.openapi.model.Type;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
/*******************************************************************************
|
||||
** The EDI 1.0.0 UI API: four spec'd operations in the default qbit namespace.
|
||||
** The EDI 1.1.0 UI API: four spec'd operations in the default qbit namespace.
|
||||
*******************************************************************************/
|
||||
public final class EdiApi
|
||||
{
|
||||
@@ -68,7 +69,7 @@ public final class EdiApi
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("edi").withSemanticVersion("1.0.0")
|
||||
return (new MiddlewareSpecExtension().withName("edi").withSemanticVersion("1.1.0")
|
||||
.withDescription("EDI sandbox and mapper, using the signed-in user's permissions.")
|
||||
.withSpec(GuideTreeSpec.class).withSpec(MapTargetSpec.class).withSpec(PreviewSpec.class).withSpec(ValidateSpec.class));
|
||||
}
|
||||
@@ -148,11 +149,11 @@ public final class EdiApi
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request or sample exceeds 5 MiB", "EdiErrorV1"),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "EdiErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "EdiErrorV1"),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Missing or invisible record", "EdiErrorV1"),
|
||||
new BasicResponse(HttpStatus.CONTENT_TOO_LARGE, "Host HTTP request size limit exceeded", "EdiErrorV1")));
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request or sample exceeds 5 MiB", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Missing or invisible record", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.CONTENT_TOO_LARGE, "Host HTTP request size limit exceeded", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -489,7 +489,7 @@ public final class EdiSchemas
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Core middleware error body.")
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
@@ -47,6 +49,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.AlertData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
@@ -56,9 +59,11 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed EDI home. Every data query explicitly enforces the viewing user's access.
|
||||
@@ -67,9 +72,31 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
{
|
||||
public static final List<String> ACTIVITY = List.of("edi_transaction", "edi_interchange", "edi_exception");
|
||||
public static final List<String> SETUP = List.of("edi_partner", "edi_agreement", "edi_transport", "edi_certificate", "edi_guide", "edi_code_list", "edi_map");
|
||||
private static final Map<String, String> LABELS = new LinkedHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** System-audience widgets (component and queue health, dead letters,
|
||||
** certificate expiry, setup and configuration). They are not on the EDI
|
||||
** business home: a host places them on its Admin home. Each is gated to
|
||||
** ADMIN_PERMISSION_BASE_NAME and hidden from everyone else.
|
||||
*******************************************************************************/
|
||||
public static final List<String> ADMIN_WIDGET_NAMES = List.of("edi_home_system", "edi_home_dead", "edi_home_certificates", "edi_home_setup");
|
||||
|
||||
/*******************************************************************************
|
||||
** Business widgets on the EDI home, in order. Each is gated to the EDI app
|
||||
** permission (edi.hasAccess) and hidden from everyone else.
|
||||
*******************************************************************************/
|
||||
public static final List<String> HOME_WIDGET_NAMES = List.of("edi_home_delayed", "edi_home_in_today", "edi_home_out_today", "edi_home_exceptions",
|
||||
"edi_home_overdue", "edi_home_queue", "edi_home_acks", "edi_home_errors", "edi_home_volume");
|
||||
|
||||
/*******************************************************************************
|
||||
** The EDI administrator permission (edi.admin.hasAccess), as on the admin processes.
|
||||
*******************************************************************************/
|
||||
public static final String ADMIN_PERMISSION_BASE_NAME = EdiQBitConfig.PERMISSION_BASE_NAME + ".admin";
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(EdiHome.class);
|
||||
private static final Map<String, String> LABELS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> DOCUMENT_NAMES = new HashMap<>();
|
||||
private static final List<String> TABLES = List.of("setup", "queue", "acks", "errors", "volume", "system");
|
||||
private static final List<String> TABLES = List.of("setup", "queue", "acks", "errors", "volume", "system");
|
||||
|
||||
static
|
||||
{
|
||||
@@ -79,6 +106,7 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
Map.entry("835", "Remittance"), Map.entry("270", "Eligibility Inquiry"), Map.entry("271", "Eligibility Response"), Map.entry("276", "Claim Status Inquiry"),
|
||||
Map.entry("277", "Claim Status Response"), Map.entry("834", "Enrollment"), Map.entry("997", "Functional Ack"), Map.entry("999", "Implementation Ack"),
|
||||
Map.entry("TA1", "Interchange Ack")));
|
||||
LABELS.put("delayed", "Inbound documents delayed");
|
||||
LABELS.put("in_today", "Inbound today");
|
||||
LABELS.put("out_today", "Outbound today");
|
||||
LABELS.put("exceptions", "Open exceptions");
|
||||
@@ -99,7 +127,7 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
public static QAppMetaData app(EdiQBitConfig config, List<QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel()).withIcon(new QIcon("swap_horiz"))
|
||||
.withWidgets(LABELS.keySet().stream().map(n -> "edi_home_" + n).toList())
|
||||
.withWidgets(HOME_WIDGET_NAMES)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
|
||||
.withSection(new QAppSection().withName("activity").withLabel("Activity").withTables(ACTIVITY))
|
||||
.withSection(new QAppSection().withName("setup").withLabel("Setup").withTables(SETUP));
|
||||
@@ -109,15 +137,23 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 typed statistics and tables; no raw markup in metadata or widget data.
|
||||
** RC.5 typed statistics, tables and one alert. Business widgets need the EDI
|
||||
** app permission; the ADMIN_WIDGET_NAMES need the EDI administrator
|
||||
** permission. Denied widgets are hidden, never shown as locked shells.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
LABELS.forEach((name, label) -> widgets.add(new QWidgetMetaData().withName("edi_home_" + name).withLabel(label)
|
||||
.withType((TABLES.contains(name) ? WidgetType.TABLE : WidgetType.STATISTICS).getType())
|
||||
.withGridColumns(TABLES.contains(name) ? 12 : 2).withCodeReference(new QCodeReference(EdiHome.class))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))));
|
||||
LABELS.forEach((name, label) ->
|
||||
{
|
||||
String widgetName = "edi_home_" + name;
|
||||
boolean admin = ADMIN_WIDGET_NAMES.contains(widgetName);
|
||||
String type = name.equals("delayed") ? WidgetType.ALERT.getType() : (TABLES.contains(name) ? WidgetType.TABLE : WidgetType.STATISTICS).getType();
|
||||
widgets.add(new QWidgetMetaData().withName(widgetName).withLabel(label).withType(type)
|
||||
.withGridColumns(TABLES.contains(name) || admin || name.equals("delayed") ? 12 : 2).withCodeReference(new QCodeReference(EdiHome.class))
|
||||
.withPermissionRules(new QPermissionRules().withPermissionBaseName(admin ? ADMIN_PERMISSION_BASE_NAME : EdiQBitConfig.PERMISSION_BASE_NAME)
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withDenyBehavior(DenyBehavior.HIDDEN)));
|
||||
});
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
@@ -137,6 +173,7 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
case "exceptions" -> count("edi_exception", openExceptions());
|
||||
case "overdue" -> count("edi_functional_group", overdueAcks(now));
|
||||
case "dead" -> count("edi_exception", openExceptions().withCriteria("type", QCriteriaOperator.IN, "DEAD_LETTER", "TRANSPORT"));
|
||||
case "delayed" -> delayed(now);
|
||||
case "certificates" -> count("edi_certificate", new QQueryFilter().withCriteria("notAfter", QCriteriaOperator.LESS_THAN_OR_EQUALS, now.plus(30, ChronoUnit.DAYS))
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
|
||||
case "setup" -> setup();
|
||||
@@ -219,9 +256,10 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A count the viewer may not be allowed to read: -1 instead of an error.
|
||||
** A count that may fail (for example a table the viewer cannot read): null,
|
||||
** shown as "Unavailable", and logged; never a made-up number.
|
||||
*******************************************************************************/
|
||||
private static int safeCount(String table, QQueryFilter filter)
|
||||
static Integer safeCount(String table, QQueryFilter filter)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -229,12 +267,58 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
return (-1);
|
||||
LOG.warn("EDI setup count unavailable", e, logPair("table", table));
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The business face of a stalled inbound flow: when the oldest unprocessed
|
||||
** inbound document is older than the configured threshold, a warning with its
|
||||
** age that links to those documents. Hidden when nothing is delayed, or when
|
||||
** the viewer cannot read the inbound documents.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput delayed(Instant now) throws QException
|
||||
{
|
||||
Integer minutes = EdiRuntime.current().config().getInboundDelayedAfterMinutes();
|
||||
Instant cutoff = now.minus(minutes, ChronoUnit.MINUTES);
|
||||
List<QRecord> oldest = List.of();
|
||||
try
|
||||
{
|
||||
oldest = query("edi_inbox", delayedInbound(cutoff).withOrderBy(new QFilterOrderBy("receivedAt", true)).withLimit(1));
|
||||
}
|
||||
catch(QPermissionDeniedException e)
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// omitted, not locked: the viewer cannot open the inbox //
|
||||
///////////////////////////////////////////////////////////
|
||||
}
|
||||
if(oldest.isEmpty())
|
||||
{
|
||||
return (new RenderWidgetOutput(new AlertData().withHideWidget(true)));
|
||||
}
|
||||
int count = countOf("edi_inbox", delayedInbound(cutoff));
|
||||
String age = humanize(Duration.between(oldest.getFirst().getValueInstant("receivedAt"), now));
|
||||
String html = "<strong>Inbound documents are delayed.</strong> " + count + (count == 1 ? " document has" : " documents have")
|
||||
+ " waited more than " + humanize(Duration.ofMinutes(minutes)) + " to be processed; the oldest has waited " + age + ". "
|
||||
+ "<a href=\"" + link("edi_inbox", delayedInbound(cutoff)) + "\">View delayed documents</a>";
|
||||
return (new RenderWidgetOutput(new AlertData(AlertData.AlertType.WARNING, html)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unprocessed inbound documents received before the cutoff.
|
||||
*******************************************************************************/
|
||||
static QQueryFilter delayedInbound(Instant cutoff)
|
||||
{
|
||||
return (inboxBacklog().withCriteria("receivedAt", QCriteriaOperator.LESS_THAN, cutoff));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open exceptions, oldest first, in four narrow columns.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -34,11 +34,12 @@ public final class EdiSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** DONE: working. TODO: the exchange needs it. BUILT_IN: provided by the
|
||||
** qbit. OPTIONAL: only some setups need it. UNKNOWN: the viewer cannot see it.
|
||||
** qbit. OPTIONAL: only some setups need it. UNKNOWN: its count failed (for
|
||||
** example the viewer cannot read the table), shown as "Unavailable".
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
DONE("Set up"), TODO("Needs setup"), BUILT_IN("Built in"), OPTIONAL("Optional"), UNKNOWN("Not visible to you");
|
||||
DONE("Set up"), TODO("Needs setup"), BUILT_IN("Built in"), OPTIONAL("Optional"), UNKNOWN("Unavailable");
|
||||
|
||||
private final String label;
|
||||
|
||||
@@ -67,11 +68,13 @@ public final class EdiSetupStatus
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The row counts the card is built from; -1 means the viewer cannot read it.
|
||||
** Active counts are rows with isActive true (what the runtime uses).
|
||||
** The row counts the card is built from; null means the count failed and the
|
||||
** step shows as unavailable. Active counts are rows with isActive true (what
|
||||
** the runtime uses).
|
||||
*******************************************************************************/
|
||||
public record Counts(int partners, int ownIdentities, int partnerIdentities, int agreements, int activeAgreements, int agreementDocuments,
|
||||
int activeAgreementDocuments, int transports, int activeTransports, int certificates, int guides, int codeLists, int maps, int activeMaps)
|
||||
public record Counts(Integer partners, Integer ownIdentities, Integer partnerIdentities, Integer agreements, Integer activeAgreements, Integer agreementDocuments,
|
||||
Integer activeAgreementDocuments, Integer transports, Integer activeTransports, Integer certificates, Integer guides, Integer codeLists,
|
||||
Integer maps, Integer activeMaps)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -96,15 +99,15 @@ public final class EdiSetupStatus
|
||||
"Add one per transaction set and direction on Agreement Documents: pick the guide, and a map if it should become records. A document type with no row is not processed."));
|
||||
items.add(item("5. Transport", "edi_transport", c.transports(), c.activeTransports(), "transport",
|
||||
"Add the AS2, SFTP or HTTPS connection on Transports (secrets as variable names), link it on the agreement, then run Test Connection."));
|
||||
items.add(c.certificates() < 0 ? new Item("Certificates", "edi_certificate", State.UNKNOWN, "", "")
|
||||
items.add(c.certificates() == null ? new Item("Certificates", "edi_certificate", State.UNKNOWN, "", "")
|
||||
: c.certificates() == 0
|
||||
? new Item("Certificates", "edi_certificate", State.OPTIONAL, "None.", "Needed only for AS2 signing and encryption: add yours and the partner's on Certificates.")
|
||||
: new Item("Certificates", "edi_certificate", State.DONE, c.certificates() + (c.certificates() == 1 ? " certificate." : " certificates."), ""));
|
||||
items.add(c.guides() < 0 || c.codeLists() < 0 ? new Item("Guides and code lists", "edi_guide", State.UNKNOWN, "", "")
|
||||
items.add(c.guides() == null || c.codeLists() == null ? new Item("Guides and code lists", "edi_guide", State.UNKNOWN, "", "")
|
||||
: c.guides() == 0 || c.codeLists() == 0
|
||||
? new Item("Guides and code lists", "edi_guide", State.TODO, "The built-in guides are not loaded.", "The host must call EdiRuntime.initialize() at startup, after the schema exists.")
|
||||
: new Item("Guides and code lists", "edi_guide", State.BUILT_IN, c.guides() + " guides and " + c.codeLists() + " code lists.", ""));
|
||||
items.add(c.maps() < 0 ? new Item("Maps", "edi_map", State.UNKNOWN, "", "")
|
||||
items.add(c.maps() == null || c.activeMaps() == null ? new Item("Maps", "edi_map", State.UNKNOWN, "", "")
|
||||
: c.maps() == 0
|
||||
? new Item("Maps", "edi_map", State.OPTIONAL, "None.", "Needed to turn documents into records. Run Install sample maps on Maps to start from an example.")
|
||||
: c.activeMaps() == 0
|
||||
@@ -119,7 +122,7 @@ public final class EdiSetupStatus
|
||||
private static Item identities(Counts c)
|
||||
{
|
||||
String step = "2. Identities";
|
||||
if(c.ownIdentities() < 0 || c.partnerIdentities() < 0)
|
||||
if(c.ownIdentities() == null || c.partnerIdentities() == null)
|
||||
{
|
||||
return (new Item(step, "edi_identity", State.UNKNOWN, "", ""));
|
||||
}
|
||||
@@ -137,9 +140,9 @@ public final class EdiSetupStatus
|
||||
/*******************************************************************************
|
||||
** A step that needs at least one active row.
|
||||
*******************************************************************************/
|
||||
private static Item item(String step, String table, int total, int active, String noun, String next)
|
||||
private static Item item(String step, String table, Integer total, Integer active, String noun, String next)
|
||||
{
|
||||
if(total < 0 || active < 0)
|
||||
if(total == null || active == null)
|
||||
{
|
||||
return (new Item(step, table, State.UNKNOWN, "", ""));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
|
||||
package com.kingsrook.qbits.edi.flow;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
@@ -30,36 +32,80 @@ import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListe
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
/*******************************************************************************
|
||||
** Transactional source listener and explicit enqueue boundary for MANUAL/ESB.
|
||||
** The listener applies only to tables with an active RECORD_CHANGE definition
|
||||
** (cached per runtime, see {@link OutboundSourceTables}), and to the definition
|
||||
** tables themselves, whose changes invalidate that cache.
|
||||
*******************************************************************************/
|
||||
public class OutboundEnqueue implements RecordChangeListenerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(OutboundEnqueue.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** Ignore internal tables and deletes; definitions are read at event time.
|
||||
** Definition changes (any type), and inserts/updates of configured sources.
|
||||
*******************************************************************************/
|
||||
public boolean appliesTo(String table, RecordChangeType type)
|
||||
{
|
||||
return (!table.startsWith("edi_") && type != RecordChangeType.DELETE);
|
||||
EdiRuntime runtime = EdiRuntime.find();
|
||||
if(runtime == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(OutboundSourceTables.DEFINITION_TABLES.contains(table))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(table.startsWith("edi_") || type == RecordChangeType.DELETE)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (runtime.outboundSources().contains(table, QContext.getQBackendTransaction()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// if the definitions can't be read, apply - so the event runs its //
|
||||
// own queries and reports the failure, rather than silently //
|
||||
// dropping outbound work. //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
LOG.warn("Unable to read EDI outbound source tables", e, logPair("table", table));
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the event transaction, even when the caller did not put it in QContext.
|
||||
** Definitions are read once per event, not once per record.
|
||||
*******************************************************************************/
|
||||
public void onRecordsChanged(RecordChangeEvent event) throws QException
|
||||
{
|
||||
if(OutboundSourceTables.DEFINITION_TABLES.contains(event.getTableName()))
|
||||
{
|
||||
EdiRuntime.current().outboundSources().invalidate(event.getTransaction());
|
||||
return;
|
||||
}
|
||||
var previous = QContext.getQBackendTransaction();
|
||||
QContext.setTransaction(event.getTransaction());
|
||||
try
|
||||
{
|
||||
for(QRecord record : event.getRecords())
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
enqueue(event.getTableName(), record, "RECORD_CHANGE", null);
|
||||
}
|
||||
List<QRecord> definitions = definitions(event.getTableName(), OutboundSourceTables.TRIGGER);
|
||||
for(QRecord record : event.getRecords())
|
||||
{
|
||||
enqueue(event.getTableName(), record, null, definitions);
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -72,45 +118,60 @@ public class OutboundEnqueue implements RecordChangeListenerInterface
|
||||
*******************************************************************************/
|
||||
public static Integer enqueue(String table, QRecord source, String trigger, String eventId) throws QException
|
||||
{
|
||||
return (SystemWrites.call(() ->
|
||||
return (SystemWrites.call(() -> enqueue(table, source, eventId, definitions(table, trigger))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active outbound documents for the table whose trigger matches (MANUAL
|
||||
** matches every trigger) and whose agreement is active. Called inside a
|
||||
** {@link SystemWrites#call}.
|
||||
*******************************************************************************/
|
||||
static List<QRecord> definitions(String table, String trigger) throws QException
|
||||
{
|
||||
List<QRecord> matching = new ArrayList<>();
|
||||
for(QRecord document : EdiRecords.query("edi_agreement_document", EdiRecords.equal("sourceTableName", table)
|
||||
.withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true)))
|
||||
{
|
||||
int count = 0;
|
||||
var definitions = EdiRecords.query("edi_agreement_document", EdiRecords.equal("sourceTableName", table)
|
||||
.withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true));
|
||||
for(QRecord document : definitions)
|
||||
if("MANUAL".equals(trigger) || trigger.equals(document.getValueString("outboundTrigger")))
|
||||
{
|
||||
if(!"MANUAL".equals(trigger) && !trigger.equals(document.getValueString("outboundTrigger")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord agreement = OutboundWorker.row("edi_agreement", document.getValueInteger("agreementId"));
|
||||
if(!Boolean.TRUE.equals(agreement.getValueBoolean("isActive")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var metadata = QContext.getQInstance().getTable(table);
|
||||
if(!metadata.getBackendName().equals(EdiRuntime.current().config().getBackendName()))
|
||||
{
|
||||
throw new QException("Outbound source and EDI must share the transactional backend");
|
||||
}
|
||||
String filter = document.getValueString("triggerFilter");
|
||||
if(filter != null && !filter.isBlank() && EdiRecords.query(table, EdiRecords.equal(metadata.getPrimaryKeyField(), source.getValue(metadata.getPrimaryKeyField()))
|
||||
.withSubFilter(new ObjectMapper().readValue(filter, QQueryFilter.class))).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(eventId != null && !EdiRecords.query("edi_outbound_request", EdiRecords.equal("eventId", eventId)
|
||||
.withCriteria("agreementDocumentId", QCriteriaOperator.EQUALS, document.getValue("id"))).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
SystemWrites.insert("edi_outbound_request", new QRecord().withValue("agreementDocumentId", document.getValue("id"))
|
||||
.withValue("sourceTableName", table).withValue("sourceRecordId", source.getValueString(metadata.getPrimaryKeyField()))
|
||||
.withValue("eventId", eventId).withValue("status", "PENDING").withValue("attempts", 0)
|
||||
.withValue("nextAttemptAt", EdiRuntime.current().config().getClock().instant()));
|
||||
count++;
|
||||
matching.add(document);
|
||||
}
|
||||
return (count);
|
||||
}));
|
||||
}
|
||||
Set<Integer> activeAgreements = OutboundSourceTables.activeAgreementIds(matching);
|
||||
matching.removeIf(document -> !activeAgreements.contains(document.getValueInteger("agreementId")));
|
||||
return (matching);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Enqueue one source record against already-resolved definitions.
|
||||
*******************************************************************************/
|
||||
private static int enqueue(String table, QRecord source, String eventId, List<QRecord> definitions) throws Exception
|
||||
{
|
||||
int count = 0;
|
||||
for(QRecord document : definitions)
|
||||
{
|
||||
var metadata = QContext.getQInstance().getTable(table);
|
||||
if(!metadata.getBackendName().equals(EdiRuntime.current().config().getBackendName()))
|
||||
{
|
||||
throw new QException("Outbound source and EDI must share the transactional backend");
|
||||
}
|
||||
String filter = document.getValueString("triggerFilter");
|
||||
if(filter != null && !filter.isBlank() && EdiRecords.query(table, EdiRecords.equal(metadata.getPrimaryKeyField(), source.getValue(metadata.getPrimaryKeyField()))
|
||||
.withSubFilter(new ObjectMapper().readValue(filter, QQueryFilter.class))).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(eventId != null && !EdiRecords.query("edi_outbound_request", EdiRecords.equal("eventId", eventId)
|
||||
.withCriteria("agreementDocumentId", QCriteriaOperator.EQUALS, document.getValue("id"))).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
SystemWrites.insert("edi_outbound_request", new QRecord().withValue("agreementDocumentId", document.getValue("id"))
|
||||
.withValue("sourceTableName", table).withValue("sourceRecordId", source.getValueString(metadata.getPrimaryKeyField()))
|
||||
.withValue("eventId", eventId).withValue("status", "PENDING").withValue("attempts", 0)
|
||||
.withValue("nextAttemptAt", EdiRuntime.current().config().getClock().instant()));
|
||||
count++;
|
||||
}
|
||||
return (count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed 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
|
||||
*
|
||||
* https://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 com.kingsrook.qbits.edi.flow;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-runtime cache of the source tables that have an active RECORD_CHANGE
|
||||
** outbound definition on an active agreement. The record-change listener
|
||||
** consults it so writes to every other table (audits, jobs, alerts...) cost
|
||||
** nothing. It is loaded from committed data only (never in a writer's
|
||||
** transaction). A change to an agreement or document invalidates it, and
|
||||
** again once that change commits (discarding any reload that raced it); until
|
||||
** then the changing transaction bypasses the cache, so it sees its own
|
||||
** definitions. A short TTL covers changes made by another process.
|
||||
*******************************************************************************/
|
||||
public final class OutboundSourceTables
|
||||
{
|
||||
static final String TRIGGER = "RECORD_CHANGE";
|
||||
static final Set<String> DEFINITION_TABLES = Set.of("edi_agreement", "edi_agreement_document");
|
||||
|
||||
private static final Duration TTL = Duration.ofSeconds(60);
|
||||
|
||||
private final AtomicLong generation = new AtomicLong();
|
||||
private final AtomicLong loads = new AtomicLong();
|
||||
private final Set<QBackendTransaction> changingTransactions = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
|
||||
private volatile Snapshot snapshot;
|
||||
|
||||
/*******************************************************************************
|
||||
** Loaded table names, with the generation and time they were loaded at.
|
||||
*******************************************************************************/
|
||||
private record Snapshot(Set<String> tables, long generation, long loadedAtNanos)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether record changes to this table, written in this transaction (may be
|
||||
** null), can enqueue outbound work.
|
||||
*******************************************************************************/
|
||||
public boolean contains(String table, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(transaction != null && changingTransactions.contains(transaction))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
Snapshot current = snapshot;
|
||||
if(current == null || current.generation() != generation.get() || System.nanoTime() - current.loadedAtNanos() > TTL.toNanos())
|
||||
{
|
||||
current = load();
|
||||
}
|
||||
return (current.tables().contains(table));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop the cache now, and again once the changing transaction commits.
|
||||
*******************************************************************************/
|
||||
public void invalidate(QBackendTransaction transaction)
|
||||
{
|
||||
generation.incrementAndGet();
|
||||
if(transaction != null)
|
||||
{
|
||||
changingTransactions.add(transaction);
|
||||
transaction.addAfterCommitCallback(() ->
|
||||
{
|
||||
changingTransactions.remove(transaction);
|
||||
generation.incrementAndGet();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** How many times the definitions have been loaded (for tests and diagnostics).
|
||||
*******************************************************************************/
|
||||
public long loadCount()
|
||||
{
|
||||
return (loads.get());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read committed active definitions once. A load that raced an invalidation
|
||||
** is not kept as current, because it carries the older generation.
|
||||
*******************************************************************************/
|
||||
private Snapshot load() throws QException
|
||||
{
|
||||
long loadGeneration = generation.get();
|
||||
Set<String> tables = SystemWrites.call(() ->
|
||||
{
|
||||
List<QRecord> documents = committed("edi_agreement_document", new QQueryFilter()
|
||||
.withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("outboundTrigger", QCriteriaOperator.EQUALS, TRIGGER));
|
||||
Set<Integer> agreementIds = agreementIds(documents);
|
||||
Set<Integer> activeAgreements = new HashSet<>();
|
||||
if(!agreementIds.isEmpty())
|
||||
{
|
||||
committed("edi_agreement", new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("id", QCriteriaOperator.IN, agreementIds)).forEach(agreement -> activeAgreements.add(agreement.getValueInteger("id")));
|
||||
}
|
||||
Set<String> result = new HashSet<>();
|
||||
for(QRecord document : documents)
|
||||
{
|
||||
if(document.getValueString("sourceTableName") != null && activeAgreements.contains(document.getValueInteger("agreementId")))
|
||||
{
|
||||
result.add(document.getValueString("sourceTableName"));
|
||||
}
|
||||
}
|
||||
return (Set.copyOf(result));
|
||||
});
|
||||
loads.incrementAndGet();
|
||||
Snapshot loaded = new Snapshot(tables, loadGeneration, System.nanoTime());
|
||||
snapshot = loaded;
|
||||
return (loaded);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query committed rows - deliberately without the caller's transaction.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> committed(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The distinct non-null agreement ids the documents reference.
|
||||
*******************************************************************************/
|
||||
private static Set<Integer> agreementIds(List<QRecord> documents)
|
||||
{
|
||||
Set<Integer> ids = new HashSet<>();
|
||||
documents.forEach(document -> ids.add(document.getValueInteger("agreementId")));
|
||||
ids.remove(null);
|
||||
return (ids);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active agreement ids among those the documents reference, in one query in
|
||||
** the current transaction.
|
||||
*******************************************************************************/
|
||||
static Set<Integer> activeAgreementIds(List<QRecord> documents) throws QException
|
||||
{
|
||||
Set<Integer> ids = agreementIds(documents);
|
||||
Set<Integer> active = new HashSet<>();
|
||||
if(!ids.isEmpty())
|
||||
{
|
||||
for(QRecord agreement : EdiRecords.query("edi_agreement", EdiRecords.equal("isActive", true).withCriteria("id", QCriteriaOperator.IN, ids)))
|
||||
{
|
||||
active.add(agreement.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
return (active);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
bundle: qbit-edi
|
||||
version: 0.1.0-SNAPSHOT
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:edi;slot:help
|
||||
@@ -1693,6 +1693,8 @@ entries:
|
||||
summary: Compare document volume by partner and document type over seven days.
|
||||
- key: widget:edi_home_system;slot:label
|
||||
summary: Review the EDI system status and processing activity.
|
||||
- key: widget:edi_home_delayed;slot:label
|
||||
summary: Warns when incoming documents have waited longer than expected to be processed.
|
||||
- key: possibleValueSource:edi_standard;slot:help
|
||||
summary: The available choices for standard.
|
||||
- key: possibleValueSource:edi_partner_status;slot:help
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
components:
|
||||
schemas:
|
||||
BasicErrorResponseV1:
|
||||
description: "The error body of every UI API operation (core and extensions)."
|
||||
properties:
|
||||
error:
|
||||
description: "The error message (the same as message; kept for clients of\
|
||||
\ UI API 1.0 to 1.5)."
|
||||
type: "string"
|
||||
code:
|
||||
description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
|
||||
\ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
|
||||
\ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
|
||||
\ Extensions may add codes; treat an unknown code by the HTTP status.\
|
||||
\ Since 1.6.0."
|
||||
type: "string"
|
||||
message:
|
||||
description: "A message to show the user. Since 1.6.0."
|
||||
type: "string"
|
||||
fieldErrors:
|
||||
description: "Errors about particular request fields, when known. Since\
|
||||
\ 1.6.0."
|
||||
items:
|
||||
properties:
|
||||
field:
|
||||
description: "The field (or parameter) name."
|
||||
type: "string"
|
||||
message:
|
||||
description: "The error about that field."
|
||||
type: "string"
|
||||
code:
|
||||
description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
|
||||
\ PATTERN."
|
||||
type: "string"
|
||||
type: "object"
|
||||
type: "array"
|
||||
traceId:
|
||||
description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
|
||||
\ response header); quote it when reporting a problem. Since 1.6.0."
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiAckV1:
|
||||
description: "Concatenated acknowledgement previews; type is 997, 999, TA1,\
|
||||
\ CONTRL, NONE or MIXED. text is null for PHI."
|
||||
@@ -93,7 +132,7 @@ components:
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiErrorV1:
|
||||
description: "Core middleware error body."
|
||||
description: "Deprecated since 1.1.0: errors use BasicErrorResponseV1."
|
||||
properties:
|
||||
error:
|
||||
nullable: true
|
||||
@@ -397,21 +436,55 @@ components:
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
bearerAuth:
|
||||
bearerFormat: "JWT or session UUID"
|
||||
description: "`Authorization: Bearer <token>`, where the token is either\n-\
|
||||
\ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
|
||||
\ to the app's client,\n or to its native client (`values.nativeClientId`\
|
||||
\ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
|
||||
\ grant or auth-code + PKCE. It is verified against the\n provider's keys\
|
||||
\ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
|
||||
\ as signing in on the web. When it expires (401), refresh it at the provider\
|
||||
\ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
|
||||
\ which the server keeps refreshed."
|
||||
scheme: "bearer"
|
||||
type: "http"
|
||||
oauth2:
|
||||
description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
|
||||
\ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
|
||||
\ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
|
||||
\ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
|
||||
\ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
|
||||
\ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
|
||||
\ token\nas `Authorization: Bearer`."
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: "/authorize"
|
||||
scopes:
|
||||
email: "Email address"
|
||||
offline_access: "A refresh token, to renew the access token without signing\
|
||||
\ in again"
|
||||
openid: "OpenID Connect sign-in"
|
||||
profile: "Name, username and groups (permissions come from the groups)"
|
||||
tokenUrl: "/token"
|
||||
type: "oauth2"
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "EDI sandbox and mapper, using the signed-in user's permissions."
|
||||
title: "QQQ Middleware API extension: edi"
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/qbit/edi/guides/{guideId}/tree:
|
||||
get:
|
||||
description: "Read the guide tree. Uses the caller session and table read permissions.\
|
||||
\ Preview also requires edi.map.preview. Rule writes use core table CRUD."
|
||||
operationId: "getQbitEdiGuidesByGuideIdTree"
|
||||
parameters:
|
||||
- description: "Positive record identifier."
|
||||
in: "path"
|
||||
@@ -424,25 +497,25 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
@@ -454,17 +527,39 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Read the guide tree"
|
||||
tags:
|
||||
- "edi"
|
||||
/qqq/v1/qbit/edi/maps/{mapId}/target:
|
||||
get:
|
||||
description: "Read target metadata and map rules. Uses the caller session and\
|
||||
\ table read permissions. Preview also requires edi.map.preview. Rule writes\
|
||||
\ use core table CRUD."
|
||||
operationId: "getQbitEdiMapsByMapIdTarget"
|
||||
parameters:
|
||||
- description: "Positive record identifier."
|
||||
in: "path"
|
||||
@@ -477,25 +572,25 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
@@ -507,17 +602,39 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Read target metadata and map rules"
|
||||
tags:
|
||||
- "edi"
|
||||
/qqq/v1/qbit/edi/sandbox/preview:
|
||||
post:
|
||||
description: "Preview an encrypted sample with optional unsaved rules. Uses\
|
||||
\ the caller session and table read permissions. Preview also requires edi.map.preview.\
|
||||
\ Rule writes use core table CRUD."
|
||||
operationId: "postQbitEdiSandboxPreview"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@@ -529,25 +646,25 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
@@ -559,17 +676,39 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Preview an encrypted sample with optional unsaved rules"
|
||||
tags:
|
||||
- "edi"
|
||||
/qqq/v1/qbit/edi/maps/{mapId}/validate:
|
||||
post:
|
||||
description: "Validate unsaved map rules. Uses the caller session and table\
|
||||
\ read permissions. Preview also requires edi.map.preview. Rule writes use\
|
||||
\ core table CRUD."
|
||||
operationId: "postQbitEdiMapsByMapIdValidate"
|
||||
parameters:
|
||||
- description: "Positive record identifier."
|
||||
in: "path"
|
||||
@@ -588,25 +727,25 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
@@ -618,9 +757,36 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Validate unsaved map rules"
|
||||
tags:
|
||||
- "edi"
|
||||
servers:
|
||||
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
|
||||
url: "/"
|
||||
tags:
|
||||
- description: "Operations of the edi extension."
|
||||
name: "edi"
|
||||
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -35,7 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class EdiApiContractTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Use RC.6's real helper, including compatibility comparison against this first baseline.
|
||||
** Check the generated contract and compatibility against the committed baseline.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contract() throws Exception
|
||||
@@ -53,15 +53,15 @@ class EdiApiContractTest
|
||||
assertThat(UiApiContract.check(generated, committed, committed, 1,
|
||||
"mvnw.cmd -B -Dtest=EdiApiContractTest -Dedi.openapi.update=true test")).isEmpty();
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(n -> n.startsWith("Edi") && n.endsWith("V1"));
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(n -> (n.startsWith("Edi") && n.endsWith("V1")) || n.equals("BasicErrorResponseV1"));
|
||||
assertThat(model.getPaths()).hasSize(4);
|
||||
assertThat(model.getPaths().keySet()).allMatch(n -> n.startsWith("/qqq/v1/qbit/edi/"));
|
||||
assertThat(extension.getEffectivePathPrefix()).isEqualTo("/qbit/edi");
|
||||
assertThat(extension.getSemanticVersion()).isEqualTo("1.0.0");
|
||||
assertThat(extension.getSemanticVersion()).isEqualTo("1.1.0");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer registers the extension and all metadata validates with RC.6.
|
||||
** Producer registers the extension and all metadata validates with core middleware.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registration() throws Exception
|
||||
|
||||
@@ -169,8 +169,18 @@ class EdiApiHttpTest
|
||||
private HttpResponse<String> request(String method, String path, Object body, String role) throws Exception
|
||||
{
|
||||
String text = body == null ? "" : body instanceof String s ? s : JSON.writeValueAsString(body);
|
||||
return (client.send(HttpRequest.newBuilder(URI.create(baseUrl + path)).header("Cookie", "sessionUUID=" + role + suffix)
|
||||
.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(text)).build(), HttpResponse.BodyHandlers.ofString()));
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(baseUrl + path)).header("Cookie", "sessionUUID=" + role + suffix)
|
||||
.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(text)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
if(response.statusCode() >= 400)
|
||||
{
|
||||
JsonNode error = JSON.readTree(response.body());
|
||||
for(String field : List.of("error", "code", "message", "traceId"))
|
||||
{
|
||||
assertThat(error.path(field).isTextual()).as(field + " in " + response.body()).isTrue();
|
||||
assertThat(error.path(field).asText()).isNotBlank();
|
||||
}
|
||||
}
|
||||
return (response);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -49,7 +49,7 @@ class EdiHelpBundleTest
|
||||
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-edi");
|
||||
assertThat(bundle.getVersion()).isEqualTo("0.1.0-SNAPSHOT");
|
||||
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(EdiQBitProducer.VERSION);
|
||||
});
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
HelpBundleAssertions.forInstance(instance)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed 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
|
||||
*
|
||||
* https://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 com.kingsrook.qbits.edi;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.app.EdiHome;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.AlertData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** The EDI home is for business users: system widgets live in a separate list
|
||||
** for the host's Admin home, gated to the EDI administrator; a stalled inbound
|
||||
** flow shows as one business item; every widget is permission-protected.
|
||||
*******************************************************************************/
|
||||
class EdiHomeAudienceTest
|
||||
{
|
||||
private static final Instant RECEIVED = Instant.parse("2026-10-16T12:00:00Z");
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void clear()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The app's widget list is the business list; system widgets stay defined,
|
||||
** are gated to edi.admin and hidden, and nothing is NOT_PROTECTED.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeWidgetListExcludesSystemWidgets() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
List<String> appWidgets = instance.getApp(EdiRuntime.current().config().getAppName()).getWidgets();
|
||||
assertThat(appWidgets).containsExactlyElementsOf(EdiHome.HOME_WIDGET_NAMES).doesNotContainAnyElementsOf(EdiHome.ADMIN_WIDGET_NAMES);
|
||||
assertThat(EdiHome.ADMIN_WIDGET_NAMES).containsExactlyInAnyOrder("edi_home_system", "edi_home_dead", "edi_home_certificates", "edi_home_setup");
|
||||
assertThat(appWidgets.getFirst()).as("attention comes first").isEqualTo("edi_home_delayed");
|
||||
|
||||
for(String name : EdiHome.ADMIN_WIDGET_NAMES)
|
||||
{
|
||||
QWidgetMetaData widget = (QWidgetMetaData) instance.getWidget(name);
|
||||
assertThat(widget).as(name).isNotNull();
|
||||
assertThat(widget.getPermissionRules().getPermissionBaseName()).as(name).isEqualTo("edi.admin");
|
||||
assertThat(widget.getGridColumns()).as(name + " is full width in its Admin placement").isEqualTo(12);
|
||||
}
|
||||
for(String name : EdiHome.HOME_WIDGET_NAMES)
|
||||
{
|
||||
assertThat(((QWidgetMetaData) instance.getWidget(name)).getPermissionRules().getPermissionBaseName()).as(name).isEqualTo("edi");
|
||||
}
|
||||
instance.getWidgets().values().stream().filter(w -> w.getName().startsWith("edi_home_")).forEach(widget ->
|
||||
{
|
||||
assertThat(widget.getPermissionRules().getLevel()).as(widget.getName()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
assertThat(widget.getPermissionRules().getDenyBehavior()).as(widget.getName()).isEqualTo(DenyBehavior.HIDDEN);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A business EDI user (edi.hasAccess, no edi.admin) gets no system widget, in
|
||||
** metadata or by rendering; an EDI administrator gets them; a user without
|
||||
** edi.hasAccess gets no home widget at all.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void businessUserCannotRenderSystemWidgets() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
|
||||
QContext.setQSession(readers(new QSession().withPermissions("edi.hasAccess")));
|
||||
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets().keySet())
|
||||
.containsAll(EdiHome.HOME_WIDGET_NAMES).doesNotContainAnyElementsOf(EdiHome.ADMIN_WIDGET_NAMES);
|
||||
for(String name : EdiHome.ADMIN_WIDGET_NAMES)
|
||||
{
|
||||
assertThatThrownBy(() -> new RenderWidgetAction().execute(render(instance, name)))
|
||||
.as(name).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
assertThat(new RenderWidgetAction().execute(render(instance, "edi_home_queue")).getWidgetData()).isInstanceOf(TableData.class);
|
||||
|
||||
QContext.setQSession(readers(new QSession().withPermissions("edi.hasAccess", "edi.admin.hasAccess")));
|
||||
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets().keySet()).containsAll(EdiHome.ADMIN_WIDGET_NAMES);
|
||||
for(String name : EdiHome.ADMIN_WIDGET_NAMES)
|
||||
{
|
||||
assertThat(new RenderWidgetAction().execute(render(instance, name)).getWidgetData()).as(name).isNotNull();
|
||||
}
|
||||
|
||||
QContext.setQSession(readers(new QSession()));
|
||||
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets().keySet()).noneMatch(name -> name.startsWith("edi_home_"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** "Inbound documents are delayed" appears only past the threshold, with the
|
||||
** age and a link to the filtered inbound documents; hidden otherwise.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void delayedInboundOnlyPastThreshold() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
assertThat(EdiRuntime.current().config().getInboundDelayedAfterMinutes()).as("single-node default").isEqualTo(60);
|
||||
QContext.setQSession(readers(new QSession().withPermissions("edi.hasAccess")));
|
||||
assertThat(delayed(instance, RECEIVED).getHideWidget()).as("nothing waiting").isTrue();
|
||||
|
||||
SystemWrites.call(() -> SystemWrites.insert("edi_inbox", new QRecord().withValue("transportType", "SFTP").withValue("externalId", "synthetic-delayed")
|
||||
.withValue("fileName", "synthetic.edi").withValue("payloadRef", "synthetic").withValue("payloadSha256", "0").withValue("sizeBytes", 1)
|
||||
.withValue("receivedAt", RECEIVED).withValue("status", "PENDING").withValue("attempts", 0).withValue("nextAttemptAt", RECEIVED)));
|
||||
|
||||
assertThat(delayed(instance, RECEIVED.plus(30, ChronoUnit.MINUTES)).getHideWidget()).as("within the threshold").isTrue();
|
||||
|
||||
AlertData alert = delayed(instance, RECEIVED.plus(95, ChronoUnit.MINUTES));
|
||||
assertThat(alert.getHideWidget()).isFalse();
|
||||
assertThat(alert.getAlertType()).isEqualTo(AlertData.AlertType.WARNING);
|
||||
assertThat(alert.getHtml()).contains("Inbound documents are delayed", "1 document has waited more than 1 h", "the oldest has waited 1 h 35 m")
|
||||
.contains("href=\"/app/edi_inbox?filter=").doesNotContain("-1");
|
||||
String filter = URLDecoder.decode(alert.getHtml().replaceAll(".*\\?filter=([^\"]*)\".*", "$1"), StandardCharsets.UTF_8);
|
||||
assertThat(filter).contains("receivedAt", "LESS_THAN", "PENDING");
|
||||
|
||||
EdiRuntime.current().config().withInboundDelayedAfterMinutes(120);
|
||||
assertThat(delayed(instance, RECEIVED.plus(95, ChronoUnit.MINUTES)).getHideWidget()).as("configurable threshold").isTrue();
|
||||
EdiRuntime.current().config().withInboundDelayedAfterMinutes(60);
|
||||
|
||||
QContext.setQSession(new QSession().withPermissions("edi.hasAccess").withSecurityKeyValue("edi.admin", true));
|
||||
assertThat(delayed(instance, RECEIVED.plus(95, ChronoUnit.MINUTES)).getHideWidget()).as("viewer cannot read inbound documents").isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A setup count that fails shows "Unavailable", never -1.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void setupCountFailureIsUnavailable() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
QSession session = readers(new QSession().withPermissions("edi.hasAccess", "edi.admin.hasAccess"));
|
||||
session.getPermissions().remove("edi.transport.read");
|
||||
QContext.setQSession(session);
|
||||
var data = (TableData) new RenderWidgetAction().execute(render(instance, "edi_home_setup")).getWidgetData();
|
||||
assertThat(data.getRows()).anySatisfy(row -> assertThat(row).containsEntry("Step", "5. Transport").containsEntry("Status", "Unavailable"));
|
||||
assertThat(data.getRows()).allSatisfy(row -> assertThat(row.values()).noneMatch(value -> String.valueOf(value).contains("-1")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Render the delayed-inbound alert at a given time.
|
||||
*******************************************************************************/
|
||||
private static AlertData delayed(QInstance instance, Instant now) throws Exception
|
||||
{
|
||||
EdiRuntime.current().config().withClock(Clock.fixed(now, ZoneOffset.UTC));
|
||||
return ((AlertData) new RenderWidgetAction().execute(render(instance, "edi_home_delayed")).getWidgetData());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A render input that enforces the viewer's permissions, as the UI API does.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetInput render(QInstance instance, String widgetName)
|
||||
{
|
||||
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(instance.getWidget(widgetName));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (input);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Add read on every EDI table and all-partner row access.
|
||||
*******************************************************************************/
|
||||
private static QSession readers(QSession session)
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
if(table.getName().startsWith("edi_"))
|
||||
{
|
||||
session.withPermissions(PermissionsHelper.getEffectivePermissionBaseName(PermissionsHelper.getEffectivePermissionRules(table, instance), table.getName()) + ".read");
|
||||
}
|
||||
}
|
||||
return (session.withSecurityKeyValue("edi.admin", true));
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,8 @@ class EdiQBitProducerTest
|
||||
assertThat(producer.getQBitConfig()).isSameAs(config);
|
||||
assertThat(producer.getNamespace()).isEqualTo("ns");
|
||||
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-edi");
|
||||
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(EdiQBitProducer.VERSION);
|
||||
assertThat(producer.getPackageNameForFindingMetaDataProducers()).isEqualTo("com.kingsrook.qbits.edi.metadata");
|
||||
assertThat(config.getAppName()).isEqualTo("edi");
|
||||
assertThat(config.getAppLabel()).isEqualTo("EDI");
|
||||
|
||||
@@ -77,8 +77,9 @@ class EdiSetupStatusTest
|
||||
items = EdiSetupStatus.items(new EdiSetupStatus.Counts(1, 1, 1, 2, 0, 1, 1, 1, 1, 0, 41, 20, 0, 0));
|
||||
assertThat(items.get(2).state()).as("agreements exist but none is active, so the runtime ignores them").isEqualTo(EdiSetupStatus.State.TODO);
|
||||
|
||||
items = EdiSetupStatus.items(new EdiSetupStatus.Counts(-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1));
|
||||
items = EdiSetupStatus.items(new EdiSetupStatus.Counts(null, null, null, null, null, null, null, null, null, null, null, null, null, null));
|
||||
assertThat(items).allSatisfy(item -> assertThat(item.state()).isEqualTo(EdiSetupStatus.State.UNKNOWN));
|
||||
assertThat(EdiSetupStatus.State.UNKNOWN.label()).as("a failed count is never shown as a number").isEqualTo("Unavailable");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -90,7 +91,8 @@ class EdiSetupStatusTest
|
||||
QInstance instance = H2Support.create();
|
||||
EdiRuntime.current().initialize();
|
||||
assertThat(instance.getWidget("edi_home_setup")).isNotNull();
|
||||
assertThat(instance.getApp(EdiRuntime.current().config().getAppName()).getWidgets()).contains("edi_home_setup");
|
||||
assertThat(instance.getApp(EdiRuntime.current().config().getAppName()).getWidgets()).as("setup is for the Admin home").doesNotContain("edi_home_setup");
|
||||
assertThat(com.kingsrook.qbits.edi.app.EdiHome.ADMIN_WIDGET_NAMES).contains("edi_home_setup");
|
||||
|
||||
Map<String, String> status = status(instance);
|
||||
assertThat(status).containsKeys("1. Partner", "5. Transport");
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed 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
|
||||
*
|
||||
* https://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 com.kingsrook.qbits.edi;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.flow.OutboundEnqueue;
|
||||
import com.kingsrook.qbits.edi.flow.OutboundSourceTables;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** The record-change listener only costs queries for configured source tables,
|
||||
** and its cache follows agreement and document changes.
|
||||
*******************************************************************************/
|
||||
class OutboundSourceTablesTest
|
||||
{
|
||||
private QRecord document;
|
||||
private QRecord agreement;
|
||||
|
||||
/*******************************************************************************
|
||||
** One active RECORD_CHANGE agreement document, for sample_order.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.create();
|
||||
RetailSupport.tables();
|
||||
EdiRuntime.current().initialize();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
QRecord partner = SystemWrites.insert("edi_partner", new QRecord().withValue("name", "Retail test").withValue("code", "RETAIL").withValue("status", "ACTIVE"));
|
||||
QRecord local = SystemWrites.insert("edi_identity", new QRecord().withValue("standard", "X12").withValue("interchangeQualifier", "ZZ").withValue("interchangeId", "QQQTEST").withValue("applicationCode", "QQQTEST"));
|
||||
QRecord remote = SystemWrites.insert("edi_identity", new QRecord().withValue("partnerId", partner.getValue("id")).withValue("standard", "X12").withValue("interchangeQualifier", "ZZ").withValue("interchangeId", "SYNTHETIC").withValue("applicationCode", "SYNTHETIC"));
|
||||
QRecord transport = SystemWrites.insert("edi_transport", new QRecord().withValue("name", "retail-out").withValue("type", "GATEWAY").withValue("direction", "BOTH").withValue("gatewayEndpointName", "retail-outbox").withValue("isActive", true).withValue("maxAttempts", 3));
|
||||
agreement = SystemWrites.insert("edi_agreement", new QRecord().withValue("partnerId", partner.getValue("id")).withValue("localIdentityId", local.getValue("id")).withValue("partnerIdentityId", remote.getValue("id"))
|
||||
.withValue("standard", "X12").withValue("usageIndicator", "T").withValue("isActive", true).withValue("outboundTransportId", transport.getValue("id")));
|
||||
QRecord map = RetailSupport.installMap("855", "004010");
|
||||
document = SystemWrites.insert("edi_agreement_document", outboundDocument(map, "sample_order"));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reset context.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void clear()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes to a table without a definition neither apply nor reload definitions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unconfiguredTableDoesNoWork() throws Exception
|
||||
{
|
||||
OutboundEnqueue listener = new OutboundEnqueue();
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isTrue();
|
||||
long loads = sources().loadCount();
|
||||
|
||||
assertThat(listener.appliesTo("sample_invoice", RecordChangeType.INSERT)).isFalse();
|
||||
assertThat(listener.appliesTo("sample_invoice", RecordChangeType.UPDATE)).isFalse();
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.DELETE)).isFalse();
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
for(int i = 0; i < 5; i++)
|
||||
{
|
||||
RetailSupport.saveSource("sample_invoice", new QRecord().withValue("invoiceNumber", "INV-" + i));
|
||||
}
|
||||
return (null);
|
||||
}));
|
||||
|
||||
assertThat(sources().loadCount()).isEqualTo(loads);
|
||||
assertThat(rows("edi_outbound_request")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configured tables still enqueue, once per record, from one cached load.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configuredTableEnqueues() throws Exception
|
||||
{
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source())));
|
||||
long loads = sources().loadCount();
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source().withValue("poNumber", "PO-2"))));
|
||||
assertThat(sources().loadCount()).isEqualTo(loads);
|
||||
assertThat(rows("edi_outbound_request")).hasSize(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Document and agreement changes invalidate the cached tables.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void definitionChangesInvalidate() throws Exception
|
||||
{
|
||||
OutboundEnqueue listener = new OutboundEnqueue();
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isTrue();
|
||||
assertThat(listener.appliesTo("edi_agreement_document", RecordChangeType.DELETE)).isTrue();
|
||||
|
||||
update("edi_agreement_document", document.getValue("id"), "isActive", false);
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isFalse();
|
||||
|
||||
update("edi_agreement_document", document.getValue("id"), "isActive", true);
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isTrue();
|
||||
|
||||
update("edi_agreement", agreement.getValue("id"), "isActive", false);
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isFalse();
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source())));
|
||||
assertThat(rows("edi_outbound_request")).isEmpty();
|
||||
|
||||
update("edi_agreement", agreement.getValue("id"), "isActive", true);
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A transaction that adds a definition sees it before commit; a rolled-back
|
||||
** one leaves the cache on committed data.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void changingTransactionSeesItsOwnDefinitions() throws Exception
|
||||
{
|
||||
QRecord map = SystemWrites.call(() -> EdiRecords.query("edi_map", new QQueryFilter()).getFirst());
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
SystemWrites.insert("edi_agreement_document", outboundDocument(map, "sample_invoice").withValue("transactionSet", "810"));
|
||||
RetailSupport.saveSource("sample_invoice", new QRecord().withValue("invoiceNumber", "INV-1"));
|
||||
return (null);
|
||||
}));
|
||||
assertThat(rows("edi_outbound_request")).hasSize(1);
|
||||
assertThat(new OutboundEnqueue().appliesTo("sample_invoice", RecordChangeType.INSERT)).isTrue();
|
||||
|
||||
try
|
||||
{
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
SystemWrites.insert("edi_agreement_document", outboundDocument(map, "sample_inventory").withValue("transactionSet", "846"));
|
||||
throw new IllegalStateException("rollback synthetic write");
|
||||
}));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
////////////////////////////
|
||||
// expected - rolled back //
|
||||
////////////////////////////
|
||||
}
|
||||
assertThat(new OutboundEnqueue().appliesTo("sample_inventory", RecordChangeType.INSERT)).isFalse();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An active outbound RECORD_CHANGE document for the source table.
|
||||
*******************************************************************************/
|
||||
private QRecord outboundDocument(QRecord map, String sourceTable)
|
||||
{
|
||||
return (new QRecord().withValue("agreementId", agreement.getValue("id")).withValue("direction", "OUTBOUND").withValue("transactionSet", "855")
|
||||
.withValue("version", "004010").withValue("functionalIdCode", "PR").withValue("guideId", map.getValue("guideId")).withValue("mapId", map.getValue("id"))
|
||||
.withValue("sourceTableName", sourceTable).withValue("outboundTrigger", "RECORD_CHANGE").withValue("batchMode", "IMMEDIATE").withValue("ackExpectation", "997").withValue("ackDueMinutes", 1).withValue("isActive", true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Update one field through QQQ, outside any transaction.
|
||||
*******************************************************************************/
|
||||
private static void update(String table, Object id, String field, Object value) throws Exception
|
||||
{
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update(table, new QRecord().withValue("id", (java.io.Serializable) id).withValue(field, (java.io.Serializable) value));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime cache under test.
|
||||
*******************************************************************************/
|
||||
private static OutboundSourceTables sources()
|
||||
{
|
||||
return (EdiRuntime.current().outboundSources());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All rows.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> rows(String table) throws Exception
|
||||
{
|
||||
return (SystemWrites.call(() -> EdiRecords.query(table, new QQueryFilter())));
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,7 @@ class PhiCanaryTest
|
||||
var enforcement = instance.getEnforcePermissionsInCoreActions();
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
var fullSession = QContext.getQSession();
|
||||
var viewer = new com.kingsrook.qqq.backend.core.model.session.QSession().withPermissions("edi.admin").withSecurityKeyValue("edi.admin", true).withSecurityKeyValue("edi.guide.loader", true);
|
||||
var viewer = new com.kingsrook.qqq.backend.core.model.session.QSession().withPermissions("edi.admin", "edi.hasAccess", "edi.admin.hasAccess").withSecurityKeyValue("edi.admin", true).withSecurityKeyValue("edi.guide.loader", true);
|
||||
for(var table : instance.getTables().values())
|
||||
{
|
||||
if(!table.getName().startsWith("edi_") || table.getName().equals(PreviewDownloadBackend.TABLE))
|
||||
|
||||
@@ -68,7 +68,7 @@ class EdiDemoServerTest
|
||||
Set<String> properties = new HashSet<>();
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().readTree(json).fieldNames().forEachRemaining(properties::add);
|
||||
assertThat(properties).isSubsetOf("sendAsEdiTables", "defaultBackendNameForTables", "backendName", "appName", "appLabel", "parentAppName",
|
||||
"schedulerName", "maxSegmentsPerTransaction", "partnerLocks", "payloadBucketAlias", "esbDestinationName");
|
||||
"schedulerName", "maxSegmentsPerTransaction", "partnerLocks", "payloadBucketAlias", "esbDestinationName", "inboundDelayedAfterMinutes");
|
||||
assertThat(calls.get()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user