Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47c3aac1b2
|
||
|
|
bf8f40959a | ||
|
|
f7c031b793 | ||
|
|
cd9423e966 | ||
|
|
d97418447b
|
||
|
|
c15872a373
|
||
|
|
fc800b8124 | ||
|
|
30ac598952
|
||
|
|
21b0e61cbb |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
|
||||
@@ -52,7 +52,7 @@ The package layout per phase is in `docs/DESIGN.md`.
|
||||
## Dependencies
|
||||
|
||||
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
|
||||
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
`4.1.0-RC.6` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
|
||||
|
||||
## Release
|
||||
|
||||
@@ -6,6 +6,11 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
|
||||
### Added
|
||||
|
||||
- Phase f1 `edi` 1.0.0 spec'd UI API: guide tree, map target/transform metadata, encrypted base64 sample preview with unsaved rule overrides, and per-rule validation under `/qqq/v1/qbit/edi`.
|
||||
- Versioned `Edi...V1` schemas and generated OpenAPI fragment with the RC.6 contract/compatibility checks; real HTTP strict-mode permission, PHI, upload, isolation and validation tests.
|
||||
- Shared sandbox/record-action preview service, current/original guide selection, caller-session transforms, structured revision/expected diffs and value-free PHI diagnostics.
|
||||
- Transform registry argument schemas, including host-supplied schemas. Preview Map record actions now enforce the documented exact `edi.map.preview` permission through core's full-name permission checker.
|
||||
|
||||
- Phase g EDIFACT syntax 3/4 envelopes (UNA/UNB, optional UNG/UNE, UNH/UNT, UNZ), release escaping, native envelope findings, UNB identity routing, 14-digit UN control allocation and duplicate detection.
|
||||
- Original D96A/D01B ORDERS, ORDRSP, DESADV, INVOIC and CONTRL guides; ORDERS inbound and retail response outbound sample maps with composite fields and segment groups.
|
||||
- Standard-neutral document validation/mapping, mixed X12/EDIFACT inbox framing, CONTRL generation and reconciliation with overdue resolution and rejection alerts, native outbound envelopes and archived resend-as-new.
|
||||
@@ -45,8 +50,20 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
- Repository scaffold to the QRun qbit repository standard: `qbit-build-parent` 2.0.0, QQQ 4.1.0-RC.5, Maven wrapper, Checkstyle, JaCoCo gate, CircleCI qqq-orb 0.6.8, release-candidate workflow, Dependabot, community files.
|
||||
- `EdiQBitProducer` and `EdiQBitConfig` (table prefix `edi_`, permission base `edi`).
|
||||
|
||||
### Changed
|
||||
|
||||
- QQQ baseline is now 4.1.0-RC.6. EDI tables/fields and healthcare test fixtures use core `DataClassification` through `AiTableMetaData`/`AiFieldMetaData`, preserving PHI, PII, CONFIDENTIAL and PHI AI-exposure restrictions.
|
||||
- Exception Assign and new/reopened partner rejection/dead-letter exceptions send value-free core notifications after commit, with capturing-sink and PHI canary coverage.
|
||||
- Retain `PhiLogScope` for core #1147 until RC.7: RC.6 still logs RDBMS query filters and has no redaction hook or `FieldAccessHelper`. Contingent dependency-behavior APIs are also absent; shared-choice filters remain tested.
|
||||
|
||||
### Removed
|
||||
|
||||
- Reflective `EdiClassifications` RC.5 stand-in. File-gateway stand-ins remain until its API is published; the object-storage API integration is unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Shared-choice filters group partner-or-shared alternatives beneath an AND root, so core search criteria cannot admit another partner's transports.
|
||||
|
||||
- 999 now accepts SNIP 3+ findings with `E` instead of rejecting every ERROR. HIPAA acknowledgement selection follows the implementation version and enabled agreement expectation; outbound HIPAA ST carries its implementation identifier.
|
||||
- Qualified HL/NM1 anchors distinguish healthcare hierarchy levels; repeated loop names under different parents retain separate occurrences. Situational rules inherit direct ancestor segments without borrowing values from sibling occurrences.
|
||||
- Review/Result records expose identifiers only. A marked RC.5 PHI logging bridge prevents core upsert query diagnostics from copying claim/member values into logs.
|
||||
|
||||
@@ -52,7 +52,7 @@ The package layout per phase is in `docs/DESIGN.md`.
|
||||
## Dependencies
|
||||
|
||||
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
|
||||
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
`4.1.0-RC.6` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
|
||||
|
||||
## Release
|
||||
|
||||
@@ -34,7 +34,7 @@ ALTER TABLE edi_agreement ADD COLUMN syntax_identifier VARCHAR(255);
|
||||
|
||||
## Requirements and installation
|
||||
|
||||
Java 21, QQQ **4.1.0-RC.5**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
|
||||
Java 21, QQQ **4.1.0-RC.6+**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
|
||||
Apply [the PostgreSQL schema](docs/ddl/edi-postgres.sql). Targets and their associations must
|
||||
share the EDI backend so each transaction set can commit or roll back atomically.
|
||||
|
||||
@@ -57,7 +57,7 @@ a least-privilege `QSession` with target and association permissions. Applying w
|
||||
produces an APPLY exception: `no service session configured`.
|
||||
|
||||
The `edi` app has Activity/Setup sections, typed home widgets, record screens and standard
|
||||
Review/Result actions. Main tables have RC.5 search-field metadata; `EdiIntegrations.mainTables()`
|
||||
Review/Result actions. Main tables have RC.6 search-field metadata; `EdiIntegrations.mainTables()`
|
||||
also exposes the list for host search registration.
|
||||
|
||||
## Tables
|
||||
@@ -73,9 +73,60 @@ There are **26 persisted tables**, whose SQL names equal their QQQ names.
|
||||
| Storage/queue | `edi_payload`, `edi_outbound_request`, `edi_outbound_message` |
|
||||
|
||||
`edi_preview_download` is a **storage-only metadata alias**, not a SQL table. Supporting tables
|
||||
appear as child lists. Partner-scoped form choices use RC.5 contingent filters, including shared
|
||||
appear as child lists. Partner-scoped form choices use RC.6 contingent filters, including shared
|
||||
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.
|
||||
|
||||
| Method | Path after `/qqq/v1/qbit/edi` | Result |
|
||||
|---|---|---|
|
||||
| GET | `/guides/{guideId}/tree` | Nested loops, ordered segments, elements and up to 200 active codes per element |
|
||||
| GET | `/maps/{mapId}/target` | Target fields, direct associations, current rules and registered transform argument schemas |
|
||||
| POST | `/sandbox/preview` | Parse counts, positional findings, acknowledgement, mapped records and revision/expected diffs |
|
||||
| POST | `/maps/{mapId}/validate` | Per-rule path, transform and required-argument errors |
|
||||
|
||||
Preview accepts `{mapId, sampleId?, sampleBase64?, sampleName?, ruleOverrides?, guideRevision?}`.
|
||||
Supply exactly one sample source. Uploads are base64 JSON, at most **5 MiB decoded**, archived as
|
||||
encrypted `edi_map_sample` records through the same storage as Preview Map. No runtime files are
|
||||
created. Configure the host's Javalin `http.maxRequestSize` to at least 8 MiB to allow base64
|
||||
overhead and request metadata. Oversized decoded samples return 400; a smaller host limit returns 413.
|
||||
|
||||
`ruleOverrides` replaces the entire rule list in memory, including an empty list; it never saves
|
||||
rules. `CURRENT` (default) selects the newest active revision of the pinned guide's name, standard,
|
||||
version, transaction set and partner; `ORIGINAL` uses the map's pinned guide. Validate accepts
|
||||
`{rules: [...]}` and checks the pinned guide. Rule fields and `transformArgs` have the same shape
|
||||
as core table CRUD (`transformArgs` is a JSON object encoded as a string).
|
||||
|
||||
Every read enforces table permissions and partner locks with the caller's session, regardless of
|
||||
the host's default core-enforcement setting. Preview additionally requires **`edi.map.preview`**,
|
||||
shared with both Preview Map record actions. Reading target metadata requires target/association
|
||||
table read permissions; guide definitions, code lists, map rules and stored samples require their
|
||||
own table reads. LOOKUP and host transforms run as the caller, without service-account elevation.
|
||||
Only the internal PHI classification check and encrypted sample storage use the EDI system writer.
|
||||
|
||||
For PHI, `result` and `ack.text` are null and both diff lists are empty; findings retain positions
|
||||
and codes with generic messages. Mapping failures are value-free `MAP` findings. Preview never
|
||||
writes target records. Diffs contain differing document records as `{documentIndex, before, after}`;
|
||||
acknowledgements for multiple groups are concatenated, with `type: MIXED` for different ack types.
|
||||
|
||||
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:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -B '-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
|
||||
in the host's `/qqq/v1/openapi.yaml` and `/qqq/v1/version`.
|
||||
|
||||
## Inbound operation
|
||||
|
||||
`InboundSink.receive(InboundPayload)` stores encrypted bytes and SHA-256 before returning a durable
|
||||
@@ -111,7 +162,7 @@ Outbound maps support nested association paths; inbound target paths retain the
|
||||
Preview shows parse counts, SNIP findings, the would-be ack, mapped JSON, expected-result and
|
||||
previous-revision comparisons. It writes no target records. PHI previews suppress document values.
|
||||
A session-authorized encrypted JSON report is downloadable. Select a stored sample or upload
|
||||
synthetic EDI on the standard Review step. Browser uploads require the host's RC.5 Javalin
|
||||
synthetic EDI on the standard Review step. Browser uploads require the host's RC.6 Javalin
|
||||
`uploadedFileArchiveTableName` to be `edi_preview_download` and multipart handling without disk
|
||||
staging. The alias encrypts streams and persists samples; this process accepts no filesystem archive.
|
||||
Samples must contain synthetic data only.
|
||||
@@ -165,29 +216,35 @@ in memory. Database storage remains the default for demos/tests.
|
||||
|
||||
For object storage, add/install optional `qbit-object-storage:0.1.0-RC.2`, configure its bucket
|
||||
alias and select `OBJECT_STORAGE`. EDI uses only its public API and system access, with keys
|
||||
`edi/yyyy/MM/dd/kind/uuid`. Its transitive QQQ dependencies are excluded to retain RC.5.
|
||||
`edi/yyyy/MM/dd/kind/uuid`. Its transitive QQQ dependencies are excluded to retain RC.6.
|
||||
|
||||
Optional `qqq-esb` publishes setup/activity events after commit. Document events contain only ID
|
||||
and status because RC.5's standard factory otherwise copies PHI fields. Alert names are
|
||||
and status because RC.6's standard factory otherwise copies PHI fields. Alert names are
|
||||
`edi.inbox.dead-letter`, `edi.ack.overdue`, `edi.ack.rejected`, `edi.send.failed`,
|
||||
`edi.certificate.expiring`, `edi.certificate.expired` and `edi.mdn.overdue` (RC.5 requires dotted names).
|
||||
`edi.certificate.expiring`, `edi.certificate.expired` and `edi.mdn.overdue` (RC.6 requires dotted names).
|
||||
|
||||
RC.5 lacks the planned core `DataClassification` API. `EdiClassifications` marks PHI/PII/confidential
|
||||
fields and PHI fields deny AI exposure. Hosts must supply secured-data protection, database
|
||||
encryption at rest and access policies for PHI columns; markers alone do not mask fields.
|
||||
Core `DataClassification` metadata marks PHI/PII/CONFIDENTIAL fields through `AiFieldMetaData`,
|
||||
with a CONFIDENTIAL table default through `AiTableMetaData`. PHI fields deny AI exposure.
|
||||
Hosts must supply secured-data protection, database encryption at rest and access policies for PHI columns; markers alone do not mask fields.
|
||||
The home reports installed integration and storage state.
|
||||
|
||||
Assign sends a value-free `QAlerts.notify` notification to the assignee after commit. Newly opened
|
||||
or reopened REJECTED_ACK/DEAD_LETTER exceptions notify distinct assignees of OPEN/IN_PROGRESS
|
||||
exceptions for the same partner; these existing assignments define the partner's recipients.
|
||||
Repeated occurrences do not send another notification. Titles and bodies contain no document,
|
||||
partner or exception-summary values. Without an alerts integration, core uses its logging sink.
|
||||
|
||||
## Outbound and transports
|
||||
|
||||
Active OUTBOUND agreement documents select the source table, map, guide and trigger. RECORD_CHANGE
|
||||
uses core's instance listener and the writer's transaction. Source and EDI tables must share the
|
||||
backend. `triggerFilter` is a serialized QQQ `QQueryFilter`; MANUAL also honors that filter.
|
||||
RC.5 catches listener errors without failing the source write: successful enqueues roll back with
|
||||
RC.6 catches listener errors without failing the source write: successful enqueues roll back with
|
||||
the source transaction, but hosts must monitor failed listener execution.
|
||||
|
||||
`edi_outbound_enqueue` is the ESB boundary. Configure an RC.5 `EsbTrigger` to invoke it with
|
||||
`edi_outbound_enqueue` is the ESB boundary. Configure an RC.6 `EsbTrigger` to invoke it with
|
||||
`sourceTableName`, `sourceRecordId` and `eventId`; broker setup belongs to the host. Event IDs are
|
||||
unique per agreement document. RC.5's consumer runner uses JMS, so broker-free acceptance calls
|
||||
unique per agreement document. RC.6's consumer runner uses JMS, so broker-free acceptance calls
|
||||
this process directly.
|
||||
|
||||
`edi_outbound_worker` and `edi_send_worker` run every 15 seconds when a scheduler is configured.
|
||||
@@ -299,17 +356,18 @@ host tables live **only in test sources/resources** and use seeded synthetic pat
|
||||
Their NPI `1234567893` is a checksum test fixture, not an asserted provider identity.
|
||||
|
||||
**The host must encrypt the database at rest and should install qbit-secured-data.** Payloads
|
||||
are always AES-GCM encrypted; PHI columns rely on host database encryption. RC.5's
|
||||
`EdiClassifications` stand-in marks PHI/PII/CONFIDENTIAL and denies PHI AI exposure, but does not
|
||||
mask values. Without secured-data policies, a user with table access can read PHI columns.
|
||||
are always AES-GCM encrypted; PHI columns rely on host database encryption. RC.6 core
|
||||
classification metadata marks PHI/PII/CONFIDENTIAL and denies PHI AI exposure, but does not
|
||||
provide secured-data field tiers. Without secured-data policies, a user with table access can
|
||||
read PHI columns.
|
||||
The home explicitly reports this limitation. Use record-level audits on host PHI target tables
|
||||
until core classified-value audit protection is available.
|
||||
|
||||
RC.5 also logs query criteria verbatim. The marked `PhiLogScope` bridge suppresses synchronous
|
||||
logger diagnostics during PHI mapping/application and preview; ordinary value-free EDI events
|
||||
remain persisted. It changes no logger levels and does not suppress other threads. Replace both
|
||||
bridges with core secured-data support at 4.1.0-RC.6. Map samples use record-level audits to avoid
|
||||
copying expected-result PHI into audit details. Host code outside these scopes must protect its
|
||||
RC.6 still includes query criteria in RDBMS failure logs and has no logging redaction hook.
|
||||
The marked `PhiLogScope` bridge suppresses synchronous logger diagnostics during PHI mapping/application and preview; ordinary value-free EDI events
|
||||
remain persisted. It changes no logger levels and does not suppress other threads. The bridge
|
||||
tracks core #1147 (secured-data logging), with a removal/review deadline of 4.1.0-RC.7.
|
||||
Map samples use record-level audits to avoid copying expected-result PHI into audit details. Host code outside these scopes must protect its
|
||||
own logging, audits and asynchronous work.
|
||||
|
||||
Download Raw Document uses a session-bound process grant, rechecks `edi.raw.download` and record
|
||||
@@ -317,10 +375,10 @@ read/partner access at download time, streams decrypted bytes without local file
|
||||
`RAW_DOWNLOADED` with the user and row references, never content. Download filenames are generic.
|
||||
|
||||
`PhiCanaryTest` scans every five-character marker window through gateway/HTTPS intake, forced
|
||||
SNIP 2/3/4 errors, mapping/apply, 999, replay/resend, Review/Result, alerts, DEBUG logs, all EDI
|
||||
SNIP 2/3/4 errors, mapping/apply, 999, replay/resend, Review/Result, alerts/notifications, DEBUG logs, all EDI
|
||||
query exports and all home/child widgets. FULL exports exempt only PHI-classified columns;
|
||||
RC.5 HIDDEN field-lock exports have no exemptions. MASKED tiers and the optional Quick Search
|
||||
qbit require the newer secured-data integration and are not claimed by these RC.5 tests.
|
||||
RC.6 HIDDEN field-lock exports have no exemptions. MASKED tiers and the optional Quick Search
|
||||
qbit require the newer secured-data integration and are not claimed by these RC.6 tests.
|
||||
|
||||
## Build and verification
|
||||
|
||||
|
||||
+54
-23
@@ -130,7 +130,7 @@ Package layout (`com.kingsrook.qbits.edi`):
|
||||
|
||||
| Dependency | Scope | Why |
|
||||
|---|---|---|
|
||||
| QQQ `4.1.0-RC.5` (then each new RC, G7) | compile | core, `QAlerts.raise/resolve` (RC.5) |
|
||||
| QQQ `4.1.0-RC.6` (then each new RC, G7) | compile | core classification, `QAlerts.raise/resolve/notify` |
|
||||
| `qqq-middleware-javalin` | optional | hooks route provider; UI API extension (RC.6) |
|
||||
| `qqq-esb` | optional | outbound trigger from ESB table events |
|
||||
| `qqq-backend-module-filesystem` (SFTP) | compile | SFTP push and VAN polling. This is core's SFTP client, the one qbit-sftp-data-integration uses; EDI uses it directly instead of depending on that qbit (G1). Remote SFTP only; nothing local. |
|
||||
@@ -139,13 +139,20 @@ Package layout (`com.kingsrook.qbits.edi`):
|
||||
| `qbit-object-storage` (its `api` package only) | optional | raw payload storage (section 10) |
|
||||
| `qbit-file-gateway` (its `api` package only) | optional | inbound SFTP and partner-pull OUTBOX (section 5.3) |
|
||||
|
||||
Core features that arrive in RC.6, and how EDI bridges until then:
|
||||
Core features checked against the `v4.1.0-RC.6` tag:
|
||||
|
||||
| Need | Core | Until RC.6 |
|
||||
| Need | Core | RC.6 status |
|
||||
|---|---|---|
|
||||
| `DataClassification` on fields (PHI) | `actions.ai.DataClassification` (#1142) | `EdiClassifications` stand-in, `STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6)`, sets the classification reflectively when the class is present |
|
||||
| User notifications | `QAlerts.notify` (#1125) | alerts only via `QAlerts.raise` (RC.5); notifications added with the RC.6 bump |
|
||||
| UI API extension point | `MiddlewareSpecExtension` (#1128) | the sandbox is a record action (section 8.3); spec'd endpoints come in phase f |
|
||||
| `DataClassification` on fields (PHI) | `actions.ai.DataClassification` (#1142) | Uses `AiFieldMetaData.withClassification` and CONFIDENTIAL `AiTableMetaData` defaults; classification stand-in removed, including healthcare test fixtures |
|
||||
| User notifications | `QAlerts.notify` (#1125) | Assign notifies the assignee; new/reopened rejection and dead-letter exceptions notify current partner assignees (section 4.4) |
|
||||
| UI API extension point | `MiddlewareSpecExtension` (#1128) | Available in core; sandbox remains a record action until phase f |
|
||||
| Contingent choice dependency behavior | `PossibleValueDependencyBehavior`, `possibleValueSourceDependsOn` | Neither API exists in the tag; existing filters retain shared choices before a partner is selected |
|
||||
| Secured-data tiers and log redaction | `FieldAccessHelper`, logging hook (#1147) | Spec only; no implementation in the tag. Retain `PhiLogScope`, `STAND-IN(core=QRun-IO/qqq#1147, removeBy=4.1.0-RC.7)` |
|
||||
|
||||
**RC.6 baseline:** classifications and notifications now use core APIs. The PHI logging bridge
|
||||
remains because RDBMS query failures still log filters and QLogger has no classified-value
|
||||
redaction hook; review/remove it by RC.7 under #1147. FULL and HIDDEN field-lock canaries run;
|
||||
MASKED tiers await `FieldAccessHelper`. Host logging/audits outside PHI scopes still need protection.
|
||||
|
||||
The file-gateway `api` ships in that qbit's phase (a). Until it is on a published SNAPSHOT, EDI uses
|
||||
`GatewayStandIn` with the identical shape, marked `STAND-IN(core=QRun-IO/qbit-file-gateway,
|
||||
@@ -247,7 +254,11 @@ One row per document, version and direction that the agreement carries.
|
||||
- When the ack arrives, `QAlerts.resolve("edi.ack.overdue", dedupKey, note)`.
|
||||
- A rejecting ack (AK9/IK9 `R`, or partial `P`) raises `edi.ack.rejected` (ERROR) and opens a
|
||||
`REJECTED_ACK` exception.
|
||||
- From RC.6, assignees of exceptions also get a `QAlerts.notify` notification.
|
||||
- Assign sends the assignee a value-free `QAlerts.notify` notification after the update commits.
|
||||
New or reopened `REJECTED_ACK`/`DEAD_LETTER` exceptions notify distinct assignees of
|
||||
OPEN/IN_PROGRESS exceptions for the same partner (including the reopened row's assignee).
|
||||
Existing active exception assignments define partner recipients; no new assignment model is
|
||||
introduced. Coalesced repeat occurrences do not notify again. Rollbacks discard notifications.
|
||||
|
||||
## 5. Transports
|
||||
|
||||
@@ -565,16 +576,41 @@ upload is stored as a sample), and the standard Review/Result lines show:
|
||||
5. the diff against the previous map revision's result and against `expectedResultJson`.
|
||||
|
||||
Nothing is written to target tables: the preview runs the whole pipeline with a no-op applier.
|
||||
Phase f adds spec'd endpoints (`POST /qqq/v1/qbit/edi/sandbox/preview` and related) for the mapper
|
||||
UI.
|
||||
Phase f1 provides the `edi` 1.0.0 `MiddlewareSpecExtension`, with component schemas named
|
||||
`Edi...V1` and a generated, contract-tested fragment at `openapi/qqq-v1/edi.yaml`:
|
||||
|
||||
### 8.4 No-code mapper UI (phase f; specified now, built later)
|
||||
- `GET /qqq/v1/qbit/edi/guides/{guideId}/tree`
|
||||
- `GET /qqq/v1/qbit/edi/maps/{mapId}/target`
|
||||
- `POST /qqq/v1/qbit/edi/sandbox/preview`
|
||||
- `POST /qqq/v1/qbit/edi/maps/{mapId}/validate`
|
||||
|
||||
Preview Map and the HTTP preview share `MapPreviewService`. HTTP preview takes `mapId`, exactly
|
||||
one of `sampleId`/`sampleBase64`, optional `sampleName`, a complete in-memory `ruleOverrides`
|
||||
replacement, and `guideRevision`. Uploads are limited to 5 MiB decoded and stored encrypted as
|
||||
samples; the host must allow at least 8 MiB HTTP bodies for base64 overhead. `CURRENT` (HTTP default)
|
||||
selects the newest active revision in the pinned guide family and partner; `ORIGINAL` uses the
|
||||
map's pinned guide, as the record action does.
|
||||
|
||||
The response contains parse counts, positional findings, acknowledgement type/text, mapped records
|
||||
and associations, structured per-document diffs, and `phiSuppressed`. PHI returns null result/ack
|
||||
text, empty diffs and generic finding messages. Mapping errors are value-free `MAP` findings.
|
||||
The preview action permission is exactly `edi.map.preview`, with no `.hasAccess` suffix.
|
||||
Guide, sample and rule reads explicitly enforce permissions and record locks; transforms run in
|
||||
the caller session. Only classification and encrypted sample storage use the internal system writer.
|
||||
|
||||
### 8.4 No-code mapper UI (phase f1 API built; Next page follows)
|
||||
A custom Next UI page (custom UIs may be nav entries):
|
||||
- left, the guide tree (loops, segments, elements with usage and code lists); right, the target
|
||||
table's fields and associations; drag to create a rule, then choose a transform;
|
||||
- a live preview pane on a chosen sample (the sandbox endpoint), with errors inline;
|
||||
- revisions: edit a DRAFT, activate, compare revisions;
|
||||
- reads and writes only through `/qqq/v1` (CRUD on `edi_map_rule` plus the extension endpoints).
|
||||
- reads and writes only through `/qqq/v1`: the guide-tree and map-target GETs above, the sandbox
|
||||
preview POST and the map validate POST; writes remain core CRUD on `edi_map_rule`, enforcing
|
||||
`edi.map_rule.insert|edit|delete` and record locks.
|
||||
- target metadata includes direct associations and transform argument JSON Schemas from the
|
||||
registry. Validate accepts `{rules: [...]}`, returns indexed per-rule errors against the pinned
|
||||
guide/target and never executes transforms or saves rules. Guide code lists return at most
|
||||
200 active codes per element.
|
||||
|
||||
## 9. Flows
|
||||
|
||||
@@ -770,7 +806,7 @@ so.
|
||||
|
||||
## 12. HIPAA and PHI
|
||||
|
||||
- **Classification** (`DataClassification`, core #1142; the stand-in until RC.6):
|
||||
- **Classification** (`DataClassification`, core #1142; native RC.6 metadata):
|
||||
- PHI: `edi_transaction.phiBusinessKey`, `edi_transaction_error.valueExcerpt` (never written for
|
||||
PHI documents, but classified anyway), `edi_map_sample.expectedResultJson`, `edi_inbox.fileName`
|
||||
(file names often carry member or claim ids), and the payloads themselves (encrypted, never in a
|
||||
@@ -794,10 +830,10 @@ so.
|
||||
- **Canary egress tests** (phase e, the core SECURED-DATA procedure): two randomized markers per
|
||||
patient-name, member-id, claim-number and diagnosis field run through gateway and HTTPS hooks,
|
||||
validation with SNIP 2/3/4 failures, 999, map/apply, exceptions, reprocess/resend, every home and
|
||||
child widget, every persisted `edi_` table's QueryAction export, Review/Result, captured alerts
|
||||
child widget, every persisted `edi_` table's QueryAction export, Review/Result, captured alerts and notifications
|
||||
and an in-memory DEBUG log appender. Every five-character window is scanned, detecting all
|
||||
longer substrings. Only explicit PHI value/display-value columns are exempt for FULL exports;
|
||||
ciphertext bytes are checked for plaintext. RC.5 HIDDEN field locks are separately exercised
|
||||
ciphertext bytes are checked for plaintext. RC.6 HIDDEN field locks are separately exercised
|
||||
without exemptions. The storage-only download alias has no query surface. MASKED tiers and
|
||||
the optional Quick Search index await their host/core integration; they are not simulated.
|
||||
Long diagnosis markers use a widened test-only guide element so accepted-with-error fixtures
|
||||
@@ -942,7 +978,6 @@ queued acknowledgements are implemented. These RC.5 adaptations refine the execu
|
||||
Object storage uses only its published API.
|
||||
- Standard optional ESB publication follows RC.5. Document events use the core envelope/publisher
|
||||
with ID/status only because the standard factory copies every field, including PHI keys.
|
||||
- The a1 classification stand-in and host responsibility for PHI field protection remain unchanged.
|
||||
|
||||
Synthetic H2 verification covers permissions, association rollback, retry/stale claims, gateway
|
||||
storage failures, original/current replay, 997/999, ack reconciliation, previews and home renderers.
|
||||
@@ -998,13 +1033,9 @@ RC.5 adaptations implemented in this worktree:
|
||||
|
||||
## Phase e implementation notes (2026-10-14)
|
||||
|
||||
- Core RC.5 logs full query criteria, including PHI upsert keys. `PhiLogScope` is a scoped,
|
||||
marked bridge to suppress synchronous diagnostic logging during PHI map/apply and previews;
|
||||
it leaves unrelated threads and persisted value-free events intact. Remove with the core
|
||||
classification/logging bridge at RC.6. Map samples use record-level audits on RC.5 because their
|
||||
expected-result column can contain PHI. Host asynchronous code, target audits and queries outside
|
||||
these boundaries still need host secured-data protection. Raw downloads use RC.5's existing
|
||||
session-bound storage grant and an `edi_event` audit rather than a not-yet-available access log.
|
||||
- See the RC.6 baseline in section 3. Map samples use record-level audits because expected-result
|
||||
JSON can contain PHI. Raw downloads use a session-bound storage grant and an `edi_event` audit;
|
||||
a secured-data access log remains host integration work.
|
||||
- Situational rules bind to their occurrence and inherit only direct ancestor segments, so a
|
||||
sibling line's service date cannot satisfy another line. Fixed loop-head qualifiers distinguish
|
||||
HL/NM1 roles; the same 2300/2400 names can occur under subscriber and patient parents.
|
||||
@@ -1018,7 +1049,7 @@ RC.5 adaptations implemented in this worktree:
|
||||
associations. These are baseline examples, not a complete licensed TR3 implementation.
|
||||
- Child-list widgets explicitly enforce their nested queries on RC.5; the generic renderer
|
||||
otherwise bypasses field locks inside the already-authorized widget action scope.
|
||||
- MASKED-user response scanning and Quick Search indexing are not available on the pinned RC.5
|
||||
- MASKED-user response scanning and Quick Search indexing are not available on the pinned RC.6
|
||||
test classpath. FULL and actual HIDDEN-field-lock scans run now; no simulated masking is used.
|
||||
|
||||
## Phase g implementation notes (2026-10-14)
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -138,6 +138,7 @@ public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
|
||||
.withPlugin(new com.kingsrook.qbits.edi.as2.As2Transport()).withPlugin(new com.kingsrook.qbits.edi.transport.UnavailableTransport("EMAIL")));
|
||||
try
|
||||
{
|
||||
com.kingsrook.qbits.edi.api.EdiApi.register(instance);
|
||||
com.kingsrook.qqq.middleware.javalin.QJavalinMetaData.ofOrWithNew(instance).withAdditionalRouteProviderReference(
|
||||
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.transport.HttpsRouteProvider.class))
|
||||
.withAdditionalRouteProviderReference(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.as2.As2RouteProvider.class));
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.cfg.CoercionAction;
|
||||
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import com.fasterxml.jackson.databind.type.LogicalType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
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.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
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.
|
||||
*******************************************************************************/
|
||||
public final class EdiApi
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static registration only.
|
||||
*******************************************************************************/
|
||||
private EdiApi()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Install with optional Javalin from the qbit producer.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared descriptor for route binding and the committed contract.
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("edi").withSemanticVersion("1.0.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));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Common typed JSON contract; core owns authentication and error mapping.
|
||||
*******************************************************************************/
|
||||
public abstract static class EdiSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
private final String path;
|
||||
private final String response;
|
||||
private final String request;
|
||||
private final String summary;
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable operation metadata.
|
||||
*******************************************************************************/
|
||||
protected EdiSpec(String path, String response, String request, String summary)
|
||||
{
|
||||
this.path = path;
|
||||
this.response = response;
|
||||
this.request = request;
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish the method, path and permission expectations.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withPath("/qbit/edi" + path).withHttpMethod(request == null ? HttpMethod.GET : HttpMethod.POST)
|
||||
.withShortSummary(summary).withLongDescription(summary + ". Uses the caller session and table read permissions. Preview also requires edi.map.preview. Rule writes use core table CRUD."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare positive integer record identifiers.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
String name = path.contains("{guideId}") ? "guideId" : path.contains("{mapId}") ? "mapId" : null;
|
||||
return (name == null ? List.of() : List.of(new Parameter().withName(name).withIn(In.PATH).withRequired(true)
|
||||
.withDescription("Positive record identifier.").withSchema(new Schema().withType(Type.INTEGER))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All schemas use the Edi prefix and V1 suffix.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return (path.startsWith("/guides/") ? EdiSchemas.all() : Map.of());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Response component name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return (response);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional request body contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (request == null ? null : EdiSchemas.ref(request));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core middleware errors are explicitly documented.
|
||||
*******************************************************************************/
|
||||
@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")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject malformed or overflowing identifiers without echoing request content.
|
||||
*******************************************************************************/
|
||||
protected Integer id(JsonInput input, String name) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
int value = Integer.parseInt(input.getPathParam(name));
|
||||
if(value < 1)
|
||||
{
|
||||
throw (new NumberFormatException());
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException("A positive integer identifier is required"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict scalar typing prevents coercion of unsaved edits and sample identifiers.
|
||||
*******************************************************************************/
|
||||
protected <T> T body(JsonInput input, Class<T> type) throws QBadRequestException
|
||||
{
|
||||
ObjectMapper mapper = JsonMapper.builder().disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT).build();
|
||||
mapper.coercionConfigFor(LogicalType.Textual).setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
|
||||
.setCoercion(CoercionInputShape.Float, CoercionAction.Fail).setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail);
|
||||
try
|
||||
{
|
||||
if(input.getBody() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Object body required"));
|
||||
}
|
||||
return (mapper.convertValue(input.getBody(), type));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QBadRequestException("Invalid body; check property names and types"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the guide tree.
|
||||
*******************************************************************************/
|
||||
public static class GuideTreeSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public GuideTreeSpec()
|
||||
{
|
||||
super("/guides/{guideId}/tree", "EdiGuideTreeV1", null, "Read the guide tree");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().tree(id(input, "guideId")));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read target metadata and map rules.
|
||||
*******************************************************************************/
|
||||
public static class MapTargetSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public MapTargetSpec()
|
||||
{
|
||||
super("/maps/{mapId}/target", "EdiTargetV1", null, "Read target metadata and map rules");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().target(id(input, "mapId")));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview an encrypted sample with optional unsaved rules.
|
||||
*******************************************************************************/
|
||||
public static class PreviewSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public PreviewSpec()
|
||||
{
|
||||
super("/sandbox/preview", "EdiPreviewV1", "EdiPreviewRequestV1", "Preview an encrypted sample with optional unsaved rules");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().preview(body(input, EdiSchemas.EdiPreviewRequestV1.class)));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate unsaved map rules.
|
||||
*******************************************************************************/
|
||||
public static class ValidateSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public ValidateSpec()
|
||||
{
|
||||
super("/maps/{mapId}/validate", "EdiValidationV1", "EdiValidateRequestV1", "Validate unsaved map rules");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().validate(id(input, "mapId"), body(input, EdiSchemas.EdiValidateRequestV1.class)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiAssociationV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiElementV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiFieldV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiGuideTreeV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiLoopV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiPreviewRequestV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiPreviewV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiRuleErrorsV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiRuleV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiSegmentV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiTargetV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiTransformV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiValidateRequestV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiValidationV1;
|
||||
import com.kingsrook.qbits.edi.map.MapPreviewService;
|
||||
import com.kingsrook.qbits.edi.map.MapRuleValidator;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadKind;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Caller-authorized projections and sandbox orchestration, using normal QQQ actions.
|
||||
*******************************************************************************/
|
||||
public class EdiApiService
|
||||
{
|
||||
public static final int MAX_SAMPLE_BYTES = 5 * 1024 * 1024;
|
||||
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
/*******************************************************************************
|
||||
** Enforced reads preserve table permissions, field locks and partner record locks.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String table, QQueryFilter filter) throws Exception
|
||||
{
|
||||
return (EdiRecords.queryEnforced(table, filter));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A missing or invisible record has the same response.
|
||||
*******************************************************************************/
|
||||
private static QRecord row(String table, Integer id) throws Exception
|
||||
{
|
||||
if(id == null || id < 1)
|
||||
{
|
||||
throw (new QBadRequestException("A positive identifier is required"));
|
||||
}
|
||||
var records = query(table, EdiRecords.equal("id", id));
|
||||
if(records.isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
return (records.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a guide as a nested tree, preserving duplicate loop ids in separate parents.
|
||||
*******************************************************************************/
|
||||
public EdiGuideTreeV1 tree(Integer id) throws Exception
|
||||
{
|
||||
row("edi_guide", id);
|
||||
List<EdiLoopV1> roots = new ArrayList<>();
|
||||
List<EdiSegmentV1> rootSegments = new ArrayList<>();
|
||||
Map<String, EdiLoopV1> loops = new LinkedHashMap<>();
|
||||
Map<String, String> latestPaths = new LinkedHashMap<>();
|
||||
var segments = query("edi_guide_segment", EdiRecords.equal("guideId", id));
|
||||
segments.sort(Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
for(var segment : segments)
|
||||
{
|
||||
List<EdiElementV1> elements = new ArrayList<>();
|
||||
var rows = query("edi_guide_element", EdiRecords.equal("guideSegmentId", segment.getValue("id")));
|
||||
rows.sort(Comparator.comparing((QRecord r) -> r.getValueInteger("position")).thenComparing(r -> Objects.requireNonNullElse(r.getValueInteger("componentPosition"), 0)));
|
||||
for(var element : rows)
|
||||
{
|
||||
List<String> codes = List.of();
|
||||
Integer codeList = element.getValueInteger("codeListId");
|
||||
if(codeList != null)
|
||||
{
|
||||
row("edi_code_list", codeList);
|
||||
codes = query("edi_code_list_value", EdiRecords.equal("codeListId", codeList)
|
||||
.withCriteria("isActive", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, true))
|
||||
.stream().map(r -> r.getValueString("code")).sorted().limit(200).toList();
|
||||
}
|
||||
elements.add(new EdiElementV1(element.getValueInteger("position"), element.getValueInteger("componentPosition"),
|
||||
element.getValueString("elementId"), element.getValueString("name"), element.getValueString("dataType"),
|
||||
element.getValueInteger("minLength"), element.getValueInteger("maxLength"), element.getValueString("usage"), codeList, codes));
|
||||
}
|
||||
EdiSegmentV1 node = new EdiSegmentV1(segment.getValueString("segmentId"), segment.getValueString("position"), segment.getValueString("usage"),
|
||||
segment.getValueInteger("minUse"), segment.getValueInteger("maxUse"), segment.getValueString("name"), elements);
|
||||
String loopId = segment.getValueString("loopId");
|
||||
if(loopId == null || loopId.isBlank())
|
||||
{
|
||||
rootSegments.add(node);
|
||||
}
|
||||
else
|
||||
{
|
||||
String parent = latestPaths.get(segment.getValueString("parentLoopId"));
|
||||
String path = (parent == null ? "" : parent + "/") + loopId;
|
||||
EdiLoopV1 loop = loops.get(path);
|
||||
if(loop == null)
|
||||
{
|
||||
loop = new EdiLoopV1(loopId, segment.getValueInteger("loopMaxRepeat"), new ArrayList<>(), new ArrayList<>());
|
||||
loops.put(path, loop);
|
||||
(parent == null ? roots : loops.get(parent).loops()).add(loop);
|
||||
}
|
||||
latestPaths.put(loopId, path);
|
||||
loop.segments().add(node);
|
||||
}
|
||||
}
|
||||
return (new EdiGuideTreeV1(roots, rootSegments));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return visible target metadata, rules and the same registry used by mapping.
|
||||
*******************************************************************************/
|
||||
public EdiTargetV1 target(Integer id) throws Exception
|
||||
{
|
||||
QRecord map = row("edi_map", id);
|
||||
QTableMetaData table = targetTable(map);
|
||||
List<EdiAssociationV1> associations = new ArrayList<>();
|
||||
if(table.getAssociations() != null)
|
||||
{
|
||||
for(var association : table.getAssociations())
|
||||
{
|
||||
QTableMetaData child = QContext.getQInstance().getTable(association.getAssociatedTableName());
|
||||
associations.add(new EdiAssociationV1(association.getName(), child.getName(), Objects.requireNonNullElse(child.getLabel(), association.getName()), fields(child)));
|
||||
}
|
||||
}
|
||||
List<EdiRuleV1> rules = new ArrayList<>();
|
||||
for(QRecord rule : query("edi_map_rule", EdiRecords.equal("mapId", id)))
|
||||
{
|
||||
rules.add(rule(rule));
|
||||
}
|
||||
rules.sort(Comparator.comparing(r -> Objects.requireNonNullElse(r.sequence(), 0)));
|
||||
return (new EdiTargetV1(table.getName(), fields(table), associations, rules,
|
||||
EdiRuntime.current().transforms().argumentSchemas().entrySet().stream().map(e -> new EdiTransformV1(e.getKey(), e.getValue())).toList()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the configured target without accepting a client-supplied table.
|
||||
*******************************************************************************/
|
||||
private QTableMetaData targetTable(QRecord map) throws Exception
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(map.getValueString("tableName"));
|
||||
if(table == null)
|
||||
{
|
||||
throw (new QBadRequestException("The map target table is unavailable"));
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Table metadata is only exposed with the table's read permission.
|
||||
*******************************************************************************/
|
||||
private List<EdiFieldV1> fields(QTableMetaData table) throws Exception
|
||||
{
|
||||
QueryInput input = new QueryInput(table.getName());
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
PermissionsHelper.checkTablePermissionThrowing(input, TablePermissionSubType.READ);
|
||||
return (table.getFields().values().stream().map(f -> new EdiFieldV1(f.getName(), f.getLabel(), f.getType().name(), f.getIsRequired(), f.getPossibleValueSourceName())).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy only documented rule fields; do not leak persistence or diagnostic fields.
|
||||
*******************************************************************************/
|
||||
private EdiRuleV1 rule(QRecord r)
|
||||
{
|
||||
return (new EdiRuleV1(r.getValueInteger("id"), r.getValueInteger("sequence"), r.getValueString("ediPath"), r.getValueString("qqqPath"),
|
||||
r.getValueString("transform"), r.getValueString("transformArgs"), r.getValueBoolean("required"), r.getValueString("condition"), r.getValueString("defaultValue")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert unsaved edits to detached records; mapId is never accepted from a rule.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> records(List<EdiRuleV1> rules) throws Exception
|
||||
{
|
||||
if(rules == null || rules.size() > 10000 || rules.stream().anyMatch(Objects::isNull))
|
||||
{
|
||||
throw (new QBadRequestException("Supply an array of at most 10000 rules"));
|
||||
}
|
||||
List<QRecord> result = new ArrayList<>();
|
||||
for(EdiRuleV1 rule : rules)
|
||||
{
|
||||
result.add(new QRecord().withValue("id", rule.id()).withValue("sequence", rule.sequence()).withValue("ediPath", rule.ediPath())
|
||||
.withValue("qqqPath", rule.qqqPath()).withValue("transform", rule.transform()).withValue("transformArgs", rule.transformArgs())
|
||||
.withValue("required", rule.required()).withValue("condition", rule.condition()).withValue("defaultValue", rule.defaultValue()));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate all submitted rules, with stable index correlation and no persistence.
|
||||
*******************************************************************************/
|
||||
public EdiValidationV1 validate(Integer id, EdiValidateRequestV1 request) throws Exception
|
||||
{
|
||||
QRecord map = row("edi_map", id);
|
||||
targetTable(map);
|
||||
var guide = MapPreviewService.guide(map, "ORIGINAL");
|
||||
List<QRecord> rules = records(request.rules());
|
||||
List<EdiRuleErrorsV1> results = new ArrayList<>();
|
||||
for(int i = 0; i < rules.size(); i++)
|
||||
{
|
||||
QRecord rule = rules.get(i);
|
||||
results.add(new EdiRuleErrorsV1(i, rule.getValueInteger("id"), rule.getValueInteger("sequence"),
|
||||
new MapRuleValidator().validate(rule, map, guide, EdiRuntime.current().transforms())));
|
||||
}
|
||||
return (new EdiValidationV1(results));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorize before decoding or saving an upload; previews never change map rules.
|
||||
*******************************************************************************/
|
||||
public EdiPreviewV1 preview(EdiPreviewRequestV1 request) throws Exception
|
||||
{
|
||||
QRecord map = row("edi_map", request.mapId());
|
||||
AbstractActionInput permission = new AbstractActionInput();
|
||||
permission.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
PermissionsHelper.checkProcessPermissionThrowing(permission, "edi_map_preview");
|
||||
String revision = Objects.requireNonNullElse(request.guideRevision(), "CURRENT");
|
||||
if(!List.of("CURRENT", "ORIGINAL").contains(revision))
|
||||
{
|
||||
throw (new QBadRequestException("guideRevision must be CURRENT or ORIGINAL"));
|
||||
}
|
||||
if((request.sampleId() == null) == (request.sampleBase64() == null))
|
||||
{
|
||||
throw (new QBadRequestException("Supply exactly one of sampleId and sampleBase64"));
|
||||
}
|
||||
List<QRecord> overrides = request.ruleOverrides() == null ? null : records(request.ruleOverrides());
|
||||
QRecord sample;
|
||||
if(request.sampleId() != null)
|
||||
{
|
||||
sample = row("edi_map_sample", request.sampleId());
|
||||
if(!Objects.equals(sample.getValueInteger("mapId"), request.mapId()))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
String encoded = request.sampleBase64();
|
||||
if(encoded.length() > ((MAX_SAMPLE_BYTES + 2) / 3) * 4)
|
||||
{
|
||||
throw (new QBadRequestException("Sample exceeds the 5 MiB limit"));
|
||||
}
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = Base64.getDecoder().decode(encoded);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QBadRequestException("sampleBase64 must be valid base64"));
|
||||
}
|
||||
if(bytes.length == 0 || bytes.length > MAX_SAMPLE_BYTES)
|
||||
{
|
||||
throw (new QBadRequestException("Sample must contain 1 byte through 5 MiB"));
|
||||
}
|
||||
if(request.sampleName() != null && request.sampleName().length() > 255)
|
||||
{
|
||||
throw (new QBadRequestException("sampleName must be at most 255 characters"));
|
||||
}
|
||||
// As in the record action, only encrypted sample archival uses the EDI writer.
|
||||
sample = SystemWrites.call(() ->
|
||||
{
|
||||
var ref = EdiRuntime.current().payloads().put(PayloadKind.SAMPLE, new ByteArrayInputStream(bytes));
|
||||
try
|
||||
{
|
||||
return (SystemWrites.insert("edi_map_sample", new QRecord().withValue("mapId", request.mapId())
|
||||
.withValue("name", Objects.requireNonNullElse(request.sampleName(), "Synthetic upload")).withValue("payloadRef", ref.value())));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
EdiRuntime.current().payloads().delete(ref);
|
||||
throw (e);
|
||||
}
|
||||
});
|
||||
}
|
||||
return (JSON.convertValue(new MapPreviewService().preview(map, sample, overrides, revision), EdiPreviewV1.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned wire types; schemas describe values without exposing QRecord internals.
|
||||
*******************************************************************************/
|
||||
public final class EdiSchemas
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static schema catalog.
|
||||
*******************************************************************************/
|
||||
private EdiSchemas()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A reference to another versioned wire type.
|
||||
*******************************************************************************/
|
||||
public static Schema ref(String name)
|
||||
{
|
||||
return (new Schema().withRefToSchema(name));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Array schema with an explicit item shape.
|
||||
*******************************************************************************/
|
||||
private static Schema array(Schema item)
|
||||
{
|
||||
return (new Schema().withType(Type.ARRAY).withItems(item));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered guide tree. Root segments are outside loops; nested loops retain parent scope.
|
||||
*******************************************************************************/
|
||||
public record EdiGuideTreeV1(List<EdiLoopV1> loops, List<EdiSegmentV1> segments) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Ordered guide tree. Root segments are outside loops; nested loops retain parent scope.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("loops", array(ref("EdiLoopV1"))),
|
||||
Map.entry("segments", array(ref("EdiSegmentV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Guide loop, nested within its parent; repeat is the maximum occurrence count.
|
||||
*******************************************************************************/
|
||||
public record EdiLoopV1(String id, Integer repeat, List<EdiLoopV1> loops, List<EdiSegmentV1> segments) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Guide loop, nested within its parent; repeat is the maximum occurrence count.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("repeat", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("loops", array(ref("EdiLoopV1"))),
|
||||
Map.entry("segments", array(ref("EdiSegmentV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Segment definition in guide sequence order.
|
||||
*******************************************************************************/
|
||||
public record EdiSegmentV1(String id, String position, String usage, Integer min, Integer max, String name, List<EdiElementV1> elements) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Segment definition in guide sequence order.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("position", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("usage", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("min", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("max", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("elements", array(ref("EdiElementV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Element or composite component; codes contain at most 200 active codes in lexical order.
|
||||
*******************************************************************************/
|
||||
public record EdiElementV1(Integer position, Integer component, String id, String name, String type, Integer minLength, Integer maxLength, String usage, Integer codeListId, List<String> codes) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Element or composite component; codes contain at most 200 active codes in lexical order.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("position", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("component", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("id", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("type", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("minLength", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("maxLength", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("usage", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("codeListId", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("codes", array(new Schema().withType(Type.STRING).withNullable(true)))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Target metadata visible to the caller, current rules and registered transforms.
|
||||
*******************************************************************************/
|
||||
public record EdiTargetV1(String table, List<EdiFieldV1> fields, List<EdiAssociationV1> associations, List<EdiRuleV1> rules, List<EdiTransformV1> transforms) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Target metadata visible to the caller, current rules and registered transforms.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("table", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("fields", array(ref("EdiFieldV1"))),
|
||||
Map.entry("associations", array(ref("EdiAssociationV1"))),
|
||||
Map.entry("rules", array(ref("EdiRuleV1"))),
|
||||
Map.entry("transforms", array(ref("EdiTransformV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ target field metadata.
|
||||
*******************************************************************************/
|
||||
public record EdiFieldV1(String name, String label, String type, Boolean required, String possibleValueSourceName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("QQQ target field metadata.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("type", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("required", new Schema().withType(Type.BOOLEAN)),
|
||||
Map.entry("possibleValueSourceName", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct QQQ association and its target fields.
|
||||
*******************************************************************************/
|
||||
public record EdiAssociationV1(String name, String table, String label, List<EdiFieldV1> fields) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Direct QQQ association and its target fields.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("table", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("fields", array(ref("EdiFieldV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Map rule. id is optional for unsaved rules. transformArgs is a JSON object encoded as a string, as in core table CRUD.
|
||||
*******************************************************************************/
|
||||
public record EdiRuleV1(Integer id, Integer sequence, String ediPath, String qqqPath, String transform, String transformArgs, Boolean required, String condition, String defaultValue) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Map rule. id is optional for unsaved rules. transformArgs is a JSON object encoded as a string, as in core table CRUD.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sequence", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("ediPath", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("qqqPath", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("transform", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("transformArgs", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("required", new Schema().withType(Type.BOOLEAN).withNullable(true)),
|
||||
Map.entry("condition", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("defaultValue", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Registered transform and JSON Schema describing its string-valued arguments.
|
||||
*******************************************************************************/
|
||||
public record EdiTransformV1(String name, Map<String, Object> argumentSchema) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Registered transform and JSON Schema describing its string-valued arguments.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("argumentSchema", new Schema().withType(Type.OBJECT).withAdditionalProperties(true).withDescription("JSON Schema: type, properties, required and additionalProperties. All transform arguments are strings."))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** mapId is required. Supply exactly one of sampleId or sampleBase64. Uploads are at most 5 MiB decoded, stored encrypted. ruleOverrides replaces the whole rule set in memory. CURRENT (default) uses the newest active guide in the pinned guide family; ORIGINAL uses the pinned guide.
|
||||
*******************************************************************************/
|
||||
public record EdiPreviewRequestV1(Integer mapId, Integer sampleId, String sampleBase64, String sampleName, List<EdiRuleV1> ruleOverrides, String guideRevision) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("mapId is required. Supply exactly one of sampleId or sampleBase64. Uploads are at most 5 MiB decoded, stored encrypted. ruleOverrides replaces the whole rule set in memory. CURRENT (default) uses the newest active guide in the pinned guide family; ORIGINAL uses the pinned guide.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("mapId", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sampleId", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sampleBase64", new Schema().withType(Type.STRING).withNullable(true).withFormat("byte").withMaxLength(6990508)),
|
||||
Map.entry("sampleName", new Schema().withType(Type.STRING).withNullable(true).withMaxLength(255)),
|
||||
Map.entry("ruleOverrides", array(ref("EdiRuleV1")).withNullable(true)),
|
||||
Map.entry("guideRevision", new Schema().withType(Type.STRING).withNullable(true).withEnumValues(List.of("CURRENT", "ORIGINAL")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse, validate, acknowledge and map without target writes. PHI suppresses result, ack text, and both diffs.
|
||||
*******************************************************************************/
|
||||
public record EdiPreviewV1(EdiParseV1 parse, List<EdiFindingV1> findings, EdiAckV1 ack, EdiResultV1 result, EdiDiffV1 diff, Boolean phiSuppressed) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Parse, validate, acknowledge and map without target writes. PHI suppresses result, ack text, and both diffs.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("parse", ref("EdiParseV1")),
|
||||
Map.entry("findings", array(ref("EdiFindingV1"))),
|
||||
Map.entry("ack", ref("EdiAckV1")),
|
||||
Map.entry("result", ref("EdiResultV1").withNullable(true)),
|
||||
Map.entry("diff", ref("EdiDiffV1")),
|
||||
Map.entry("phiSuppressed", new Schema().withType(Type.BOOLEAN))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts of wire interchanges, groups and documents.
|
||||
*******************************************************************************/
|
||||
public record EdiParseV1(Integer interchanges, Integer groups, Integer documents) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Counts of wire interchanges, groups and documents.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("interchanges", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("groups", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("documents", new Schema().withType(Type.INTEGER).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Value-free diagnostic. PHI messages are generic; only positions and codes are retained.
|
||||
*******************************************************************************/
|
||||
public record EdiFindingV1(Integer snipLevel, String severity, Integer segmentPosition, String segmentId, String loopId, Integer elementPosition, String code, String message) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Value-free diagnostic. PHI messages are generic; only positions and codes are retained.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("snipLevel", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("segmentPosition", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("segmentId", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("loopId", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("elementPosition", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("code", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("message", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Concatenated acknowledgement previews; type is 997, 999, TA1, CONTRL, NONE or MIXED. text is null for PHI.
|
||||
*******************************************************************************/
|
||||
public record EdiAckV1(String type, String text) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Concatenated acknowledgement previews; type is 997, 999, TA1, CONTRL, NONE or MIXED. text is null for PHI.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("type", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("text", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Mapped records in document order; no target writes.
|
||||
*******************************************************************************/
|
||||
public record EdiResultV1(List<EdiRecordV1> records) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Mapped records in document order; no target writes.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("records", array(ref("EdiRecordV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Field values and recursively associated records. Values use the target table field types.
|
||||
*******************************************************************************/
|
||||
public record EdiRecordV1(Map<String, Object> values, Map<String, List<EdiRecordV1>> associations) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Field values and recursively associated records. Values use the target table field types.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("values", new Schema().withType(Type.OBJECT).withAdditionalProperties(true)),
|
||||
Map.entry("associations", new Schema().withType(Type.OBJECT).withAdditionalProperties(array(ref("EdiRecordV1"))))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only differing records are returned. Both lists are empty for PHI.
|
||||
*******************************************************************************/
|
||||
public record EdiDiffV1(List<EdiDifferenceV1> againstPreviousRevision, List<EdiDifferenceV1> againstExpected) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Only differing records are returned. Both lists are empty for PHI.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("againstPreviousRevision", array(ref("EdiDifferenceV1"))),
|
||||
Map.entry("againstExpected", array(ref("EdiDifferenceV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One document comparison; documentIndex is zero based.
|
||||
*******************************************************************************/
|
||||
public record EdiDifferenceV1(Integer documentIndex, Object before, Object after) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One document comparison; documentIndex is zero based.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("documentIndex", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("before", new Schema().withNullable(true).withDescription("Expected JSON or a previous mapped record.")),
|
||||
Map.entry("after", new Schema().withType(Type.OBJECT).withAdditionalProperties(true).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** rules is required and may be empty; rules are never persisted.
|
||||
*******************************************************************************/
|
||||
public record EdiValidateRequestV1(List<EdiRuleV1> rules) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("rules is required and may be empty; rules are never persisted.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("rules", array(ref("EdiRuleV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One result per submitted rule, including rules with no errors.
|
||||
*******************************************************************************/
|
||||
public record EdiValidationV1(List<EdiRuleErrorsV1> rules) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One result per submitted rule, including rules with no errors.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("rules", array(ref("EdiRuleErrorsV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Correlate errors using zero-based index, optional id and sequence.
|
||||
*******************************************************************************/
|
||||
public record EdiRuleErrorsV1(Integer index, Integer id, Integer sequence, List<String> errors) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Correlate errors using zero-based index, optional id and sequence.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("index", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sequence", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("errors", array(new Schema().withType(Type.STRING).withNullable(true)))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core middleware error body.
|
||||
*******************************************************************************/
|
||||
public record EdiErrorV1(String error) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Core middleware error body.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete deterministic schema catalog for all four operations.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Schema> all()
|
||||
{
|
||||
Map<String, Schema> result = new TreeMap<>();
|
||||
result.put("EdiGuideTreeV1", new EdiGuideTreeV1(null, null).toSchema());
|
||||
result.put("EdiLoopV1", new EdiLoopV1(null, null, null, null).toSchema());
|
||||
result.put("EdiSegmentV1", new EdiSegmentV1(null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiElementV1", new EdiElementV1(null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiTargetV1", new EdiTargetV1(null, null, null, null, null).toSchema());
|
||||
result.put("EdiFieldV1", new EdiFieldV1(null, null, null, null, null).toSchema());
|
||||
result.put("EdiAssociationV1", new EdiAssociationV1(null, null, null, null).toSchema());
|
||||
result.put("EdiRuleV1", new EdiRuleV1(null, null, null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiTransformV1", new EdiTransformV1(null, null).toSchema());
|
||||
result.put("EdiPreviewRequestV1", new EdiPreviewRequestV1(null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiPreviewV1", new EdiPreviewV1(null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiParseV1", new EdiParseV1(null, null, null).toSchema());
|
||||
result.put("EdiFindingV1", new EdiFindingV1(null, null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiAckV1", new EdiAckV1(null, null).toSchema());
|
||||
result.put("EdiResultV1", new EdiResultV1(null).toSchema());
|
||||
result.put("EdiRecordV1", new EdiRecordV1(null, null).toSchema());
|
||||
result.put("EdiDiffV1", new EdiDiffV1(null, null).toSchema());
|
||||
result.put("EdiDifferenceV1", new EdiDifferenceV1(null, null, null).toSchema());
|
||||
result.put("EdiValidateRequestV1", new EdiValidateRequestV1(null).toSchema());
|
||||
result.put("EdiValidationV1", new EdiValidationV1(null).toSchema());
|
||||
result.put("EdiRuleErrorsV1", new EdiRuleErrorsV1(null, null, null, null).toSchema());
|
||||
result.put("EdiErrorV1", new EdiErrorV1(null).toSchema());
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import java.util.List;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
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;
|
||||
@@ -54,16 +57,33 @@ public final class EdiJournal
|
||||
filter.withCriteria(pair[0].toString(), pair[1] == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, (Integer) pair[1]);
|
||||
}
|
||||
List<QRecord> matches = EdiRecords.query("edi_exception", filter);
|
||||
if(matches.isEmpty())
|
||||
QRecord exception;
|
||||
boolean opened = matches.isEmpty();
|
||||
if(opened)
|
||||
{
|
||||
SystemWrites.insert("edi_exception", new QRecord().withValue("inboxId", inbox).withValue("interchangeId", interchange).withValue("transactionId", transaction)
|
||||
exception = SystemWrites.insert("edi_exception", new QRecord().withValue("inboxId", inbox).withValue("interchangeId", interchange).withValue("transactionId", transaction)
|
||||
.withValue("partnerId", partner).withValue("type", type).withValue("summary", summary).withValue("status", "OPEN").withValue("occurrences", 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
QRecord row = matches.getFirst();
|
||||
exception = row;
|
||||
opened = List.of("RESOLVED", "IGNORED").contains(row.getValueString("status"));
|
||||
SystemWrites.update("edi_exception", new QRecord().withValue("id", row.getValue("id")).withValue("occurrences", row.getValueInteger("occurrences") + 1).withValue("status", "OPEN"));
|
||||
}
|
||||
if(opened && partner != null && List.of("REJECTED_ACK", "DEAD_LETTER").contains(type))
|
||||
{
|
||||
String[] assignees = EdiRecords.query("edi_exception", EdiRecords.equal("partnerId", partner)
|
||||
.withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS")).stream()
|
||||
.map(row -> row.getValueString("assignedToUserId")).filter(user -> user != null && !user.isBlank()).distinct().toArray(String[]::new);
|
||||
if(assignees.length > 0)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("EDI exception opened")
|
||||
.withBody("An acknowledgement rejection or dead-letter exception needs attention.")
|
||||
.withAudience(NotificationAudience.users(assignees))
|
||||
.withSourceName("edi").withSourceTableName("edi_exception").withSourceRecordId(exception.getValueString("id")));
|
||||
}
|
||||
}
|
||||
event(inbox, interchange, transaction, "EXCEPTION");
|
||||
}
|
||||
}
|
||||
@@ -47,24 +47,37 @@ public class GuideRepository
|
||||
*******************************************************************************/
|
||||
public Guide load(Integer id) throws Exception
|
||||
{
|
||||
QRecord guide = rows("edi_guide", "id", id).getFirst();
|
||||
List<QRecord> definitions = new ArrayList<>(rows("edi_guide_segment", "guideId", id));
|
||||
return (load(id, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** UI preview explicitly enforces every guide read, independently of host defaults.
|
||||
*******************************************************************************/
|
||||
public Guide load(Integer id, boolean enforcePermissions) throws Exception
|
||||
{
|
||||
var guideRows = rows("edi_guide", "id", id, enforcePermissions);
|
||||
if(guideRows.isEmpty())
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QNotFoundException("Not found"));
|
||||
}
|
||||
QRecord guide = guideRows.getFirst();
|
||||
List<QRecord> definitions = new ArrayList<>(rows("edi_guide_segment", "guideId", id, enforcePermissions));
|
||||
definitions.sort(Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
List<GuideSegment> segments = new ArrayList<>();
|
||||
List<QRecord> rules = rows("edi_guide_rule", "guideId", id);
|
||||
List<QRecord> rules = rows("edi_guide_rule", "guideId", id, enforcePermissions);
|
||||
for(QRecord definition : definitions)
|
||||
{
|
||||
Integer segmentId = definition.getValueInteger("id");
|
||||
List<GuideElement> elements = new ArrayList<>();
|
||||
for(QRecord element : rows("edi_guide_element", "guideSegmentId", segmentId))
|
||||
for(QRecord element : rows("edi_guide_element", "guideSegmentId", segmentId, enforcePermissions))
|
||||
{
|
||||
List<String> codes = null;
|
||||
QRecord codeList = null;
|
||||
if(element.getValueInteger("codeListId") != null)
|
||||
{
|
||||
codeList = rows("edi_code_list", "id", element.getValueInteger("codeListId")).getFirst();
|
||||
codeList = rows("edi_code_list", "id", element.getValueInteger("codeListId"), enforcePermissions).getFirst();
|
||||
codes = new ArrayList<>();
|
||||
for(QRecord code : rows("edi_code_list_value", "codeListId", element.getValueInteger("codeListId")))
|
||||
for(QRecord code : rows("edi_code_list_value", "codeListId", element.getValueInteger("codeListId"), enforcePermissions))
|
||||
{
|
||||
if(Boolean.TRUE.equals(code.getValueBoolean("isActive")))
|
||||
{
|
||||
@@ -206,9 +219,9 @@ public class GuideRepository
|
||||
/*******************************************************************************
|
||||
** Query normalized child records.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> rows(String table, String field, Integer id) throws Exception
|
||||
private static List<QRecord> rows(String table, String field, Integer id, boolean enforcePermissions) throws Exception
|
||||
{
|
||||
return (EdiRecords.query(table, EdiRecords.equal(field, id)));
|
||||
return (enforcePermissions ? EdiRecords.queryEnforced(table, EdiRecords.equal(field, id)) : EdiRecords.query(table, EdiRecords.equal(field, id)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -121,6 +121,10 @@ public class InboundMapper
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new IllegalArgumentException("Mapping failed at rule " + rule.getValueInteger("sequence") + " (" + rule.getValueString("ediPath") + " -> " + rule.getValueString("qqqPath") + ")");
|
||||
|
||||
@@ -20,183 +20,47 @@
|
||||
|
||||
package com.kingsrook.qbits.edi.map;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.ack.AckLevel;
|
||||
import com.kingsrook.qbits.edi.guide.GuideRepository;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** No-op apply preview with ordinary Result lines; PHI never enters process output.
|
||||
** Standard record-action presentation of the shared sandbox preview.
|
||||
*******************************************************************************/
|
||||
public class MapPreview
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Parse, validate, acknowledge and map a stored synthetic sample without target writes.
|
||||
** Render the same pipeline as HTTP using ordinary Review/Result lines.
|
||||
*******************************************************************************/
|
||||
public ArrayList<ProcessSummaryLineInterface> preview(QRecord map, QRecord sample) throws Exception
|
||||
{
|
||||
EdiRuntime runtime = EdiRuntime.current();
|
||||
boolean phi = List.of("837", "835", "270", "271", "276", "277", "834").contains(map.getValueString("transactionSet"));
|
||||
phi = phi || com.kingsrook.qbits.edi.support.SystemWrites.call(() -> !EdiRecords.query("edi_agreement_document", EdiRecords.equal("mapId", map.getValue("id"))
|
||||
.withCriteria("containsPhi", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, true)).isEmpty());
|
||||
var guide = new GuideRepository().load(map.getValueInteger("guideId"));
|
||||
Map<String, Object> preview = new MapPreviewService().preview(map, sample, null, "ORIGINAL");
|
||||
var json = new ObjectMapper().findAndRegisterModules();
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
try(var logging = com.kingsrook.qbits.edi.security.PhiLogScope.open(phi); InputStream stream = runtime.payloads().open(new PayloadRef(sample.getValueString("payloadRef"), null, 0)))
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "Parsed: " + json.writeValueAsString(preview.get("parse"))));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "SNIP findings: " + json.writeValueAsString(preview.get("findings"))));
|
||||
if(Boolean.TRUE.equals(preview.get("phiSuppressed")))
|
||||
{
|
||||
if("EDIFACT".equals(map.getValueString("standard")))
|
||||
{
|
||||
return (edifact(map, sample, stream, phi));
|
||||
}
|
||||
var interchanges = runtime.reader().read(stream);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, interchanges.size(), "interchanges parsed"));
|
||||
for(var interchange : interchanges)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, interchange.groups().size(), "functional groups parsed"));
|
||||
for(var group : interchange.groups())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, group.transactions().size(), "transaction sets parsed"));
|
||||
Map<String, List<com.kingsrook.qbits.edi.guide.ValidationFinding>> all = new java.util.LinkedHashMap<>();
|
||||
for(var transaction : group.transactions())
|
||||
{
|
||||
var findings = runtime.validator().validate(transaction, guide, phi);
|
||||
all.put(transaction.st().value(2), findings);
|
||||
for(int snip = 1; snip <= 7; snip++)
|
||||
{
|
||||
int level = snip;
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, (int) findings.stream().filter(f -> f.snipLevel() == level).count(), "findings at SNIP " + snip));
|
||||
}
|
||||
List<QRecord> rules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")));
|
||||
rules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
var mapped = InboundApplier.asService(runtime.config().getServiceSessionSupplier(), () -> new InboundMapper(runtime.transforms()).map(transaction, guide, map, rules));
|
||||
if(phi)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "PHI map result suppressed; no target records written"));
|
||||
}
|
||||
else
|
||||
{
|
||||
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
String actual = resultJson(mapped.record());
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, actual));
|
||||
String expected = sample.getValueString("expectedResultJson");
|
||||
if(expected != null)
|
||||
{
|
||||
diff(lines, "Expected result", json.readTree(expected), json.readTree(actual));
|
||||
}
|
||||
Integer revision = map.getValueInteger("revision");
|
||||
var previous = EdiRecords.query("edi_map", EdiRecords.equal("name", map.getValue("name")));
|
||||
previous.removeIf(r -> !java.util.Objects.equals(r.getValue("partnerId"), map.getValue("partnerId"))
|
||||
|| revision == null || r.getValueInteger("revision") == null || r.getValueInteger("revision") >= revision);
|
||||
previous.sort(java.util.Comparator.comparing((QRecord r) -> r.getValueInteger("revision")).reversed());
|
||||
if(!previous.isEmpty())
|
||||
{
|
||||
QRecord prior = previous.getFirst();
|
||||
var priorGuide = new GuideRepository().load(prior.getValueInteger("guideId"));
|
||||
List<QRecord> priorRules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", prior.getValue("id")));
|
||||
priorRules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
var priorResult = InboundApplier.asService(runtime.config().getServiceSessionSupplier(),
|
||||
() -> new InboundMapper(runtime.transforms()).map(transaction, priorGuide, prior, priorRules));
|
||||
diff(lines, "Previous revision " + prior.getValueInteger("revision"), json.readTree(resultJson(priorResult.record())), json.readTree(actual));
|
||||
}
|
||||
}
|
||||
}
|
||||
var ack = phi && group.gs().value(8).startsWith("005010X") ? runtime.ack999().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001")
|
||||
: runtime.ack997().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001");
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, phi ? "Acknowledgement preview suppressed for PHI document"
|
||||
: runtime.writer().write(com.kingsrook.qbits.edi.ack.AckEnvelope.wrap(interchange, group, ack, "1", "1", java.time.LocalDateTime.now(runtime.config().getClock())))));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview native EDIFACT messages and CONTRL without applying target records.
|
||||
*******************************************************************************/
|
||||
private ArrayList<ProcessSummaryLineInterface> edifact(QRecord map, QRecord sample, InputStream stream, boolean phi) throws Exception
|
||||
{
|
||||
var runtime = EdiRuntime.current();
|
||||
var guide = new GuideRepository().load(map.getValueInteger("guideId"));
|
||||
var rules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")));
|
||||
rules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
for(var interchange : new com.kingsrook.qbits.edi.edifact.EdifactEnvelopeReader(runtime.config().getMaxSegmentsPerTransaction()).read(stream))
|
||||
{
|
||||
Map<String, List<com.kingsrook.qbits.edi.guide.ValidationFinding>> findings = new java.util.LinkedHashMap<>();
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, interchange.findings().size(), "envelope findings"));
|
||||
for(var message : interchange.allMessages())
|
||||
{
|
||||
var result = runtime.validator().validate(message, guide, phi);
|
||||
findings.put(message.reference(), result);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, result.size(), "validation findings"));
|
||||
var mapped = InboundApplier.asService(runtime.config().getServiceSessionSupplier(), () -> new InboundMapper(runtime.transforms()).map(message, guide, map, rules));
|
||||
if(phi)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "PHI map result suppressed; no target records written"));
|
||||
continue;
|
||||
}
|
||||
String actual = resultJson(mapped.record());
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, actual));
|
||||
if(sample.getValueString("expectedResultJson") != null)
|
||||
{
|
||||
diff(lines, "Expected result", new ObjectMapper().readTree(sample.getValueString("expectedResultJson")), new ObjectMapper().readTree(actual));
|
||||
}
|
||||
}
|
||||
if(interchange.unb() != null && !phi)
|
||||
{
|
||||
var ack = new com.kingsrook.qbits.edi.edifact.ContrlBuilder().build(interchange, findings, AckLevel.ALL_TRANSACTIONS, "1", map.getValueString("version"));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, new com.kingsrook.qbits.edi.edifact.EdifactWriter().write(ack.segments(), interchange.separators(), "")));
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable record JSON excludes QRecord diagnostics and implementation metadata.
|
||||
*******************************************************************************/
|
||||
private static String resultJson(QRecord record) throws Exception
|
||||
{
|
||||
Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("values", record.getValues());
|
||||
Map<String, Object> associations = new java.util.LinkedHashMap<>();
|
||||
if(record.getAssociatedRecords() != null)
|
||||
{
|
||||
for(var entry : record.getAssociatedRecords().entrySet())
|
||||
{
|
||||
List<Object> children = new ArrayList<>();
|
||||
for(QRecord child : entry.getValue())
|
||||
{
|
||||
children.add(new ObjectMapper().readTree(resultJson(child)));
|
||||
}
|
||||
associations.put(entry.getKey(), children);
|
||||
}
|
||||
}
|
||||
result.put("associations", associations);
|
||||
return (new ObjectMapper().findAndRegisterModules().writeValueAsString(result));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Show a structured before/after diff for synthetic samples only.
|
||||
*******************************************************************************/
|
||||
private static void diff(ArrayList<ProcessSummaryLineInterface> lines, String label, com.fasterxml.jackson.databind.JsonNode before, com.fasterxml.jackson.databind.JsonNode after) throws Exception
|
||||
{
|
||||
if(before.equals(after))
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, label + " matches"));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "PHI map result suppressed; no target records written"));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "Acknowledgement preview suppressed for PHI document"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Map<String, Object> difference = Map.of("comparison", label, "before", before, "after", after);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, label + " differs: " + new ObjectMapper().writeValueAsString(difference)));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, json.writeValueAsString(preview.get("result"))));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, json.writeValueAsString(preview.get("ack"))));
|
||||
Map<?, ?> diff = (Map<?, ?>) preview.get("diff");
|
||||
for(String key : List.of("againstExpected", "againstPreviousRevision"))
|
||||
{
|
||||
String label = key.equals("againstExpected") ? "Expected result" : "Previous revision";
|
||||
List<?> differences = (List<?>) diff.get(key);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, label + (differences.isEmpty() ? " matches" : " differs: " + json.writeValueAsString(differences))));
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* 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.map;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.ack.AckEnvelope;
|
||||
import com.kingsrook.qbits.edi.ack.AckLevel;
|
||||
import com.kingsrook.qbits.edi.document.EdiDocument;
|
||||
import com.kingsrook.qbits.edi.edifact.ContrlBuilder;
|
||||
import com.kingsrook.qbits.edi.edifact.EdifactEnvelopeReader;
|
||||
import com.kingsrook.qbits.edi.edifact.EdifactWriter;
|
||||
import com.kingsrook.qbits.edi.guide.Guide;
|
||||
import com.kingsrook.qbits.edi.guide.GuideRepository;
|
||||
import com.kingsrook.qbits.edi.guide.ValidationFinding;
|
||||
import com.kingsrook.qbits.edi.security.PhiLogScope;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qbits.edi.x12.EnvelopeFinding;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared no-apply preview pipeline. All mapping, including LOOKUP, uses the caller.
|
||||
** This service has no optional middleware dependency.
|
||||
*******************************************************************************/
|
||||
public class MapPreviewService
|
||||
{
|
||||
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
/*******************************************************************************
|
||||
** Read ordered persisted rules, or order a copy of the unsaved replacement set.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> rules(QRecord map, List<QRecord> overrides) throws Exception
|
||||
{
|
||||
List<QRecord> result = new ArrayList<>(overrides == null ? EdiRecords.queryEnforced("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id"))) : overrides);
|
||||
result.sort(Comparator.comparing(r -> Objects.requireNonNullElse(r.getValueInteger("sequence"), 0)));
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** PHI classification cannot be weakened by the caller or by hidden agreements.
|
||||
*******************************************************************************/
|
||||
public static boolean containsPhi(QRecord map) throws Exception
|
||||
{
|
||||
return (healthcare(map.getValueString("transactionSet")) || SystemWrites.call(() -> !EdiRecords.query("edi_agreement_document",
|
||||
EdiRecords.equal("mapId", map.getValue("id")).withCriteria("containsPhi", QCriteriaOperator.EQUALS, true)).isEmpty()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Healthcare document types always suppress values, even with a mismatched map.
|
||||
*******************************************************************************/
|
||||
private static boolean healthcare(String type)
|
||||
{
|
||||
return (type != null && List.of("837", "835", "270", "271", "276", "277", "834").contains(type));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** ORIGINAL is the pinned row; CURRENT follows only the same guide family.
|
||||
*******************************************************************************/
|
||||
public static Guide guide(QRecord map, String revision) throws Exception
|
||||
{
|
||||
Integer id = map.getValueInteger("guideId");
|
||||
if("CURRENT".equals(revision))
|
||||
{
|
||||
var pinnedRows = EdiRecords.queryEnforced("edi_guide", EdiRecords.equal("id", id));
|
||||
if(pinnedRows.isEmpty())
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QNotFoundException("Not found"));
|
||||
}
|
||||
QRecord pinned = pinnedRows.getFirst();
|
||||
var candidates = EdiRecords.queryEnforced("edi_guide", EdiRecords.equal("name", pinned.getValue("name")).withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
|
||||
candidates.removeIf(r -> !Objects.equals(r.getValue("partnerId"), pinned.getValue("partnerId"))
|
||||
|| !Objects.equals(r.getValue("standard"), pinned.getValue("standard"))
|
||||
|| !Objects.equals(r.getValue("version"), pinned.getValue("version"))
|
||||
|| !Objects.equals(r.getValue("transactionSet"), pinned.getValue("transactionSet")));
|
||||
candidates.sort(Comparator.comparing((QRecord r) -> Objects.requireNonNullElse(r.getValueInteger("revision"), 0)).reversed());
|
||||
if(!candidates.isEmpty())
|
||||
{
|
||||
id = candidates.getFirst().getValueInteger("id");
|
||||
}
|
||||
}
|
||||
return (new GuideRepository().load(id, true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse and inspect the complete sample before exposing any values.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> preview(QRecord map, QRecord sample, List<QRecord> overrides, String revision) throws Exception
|
||||
{
|
||||
EdiRuntime runtime = EdiRuntime.current();
|
||||
boolean phi = containsPhi(map);
|
||||
Guide guide = guide(map, revision);
|
||||
List<QRecord> rules = rules(map, overrides);
|
||||
State state = new State();
|
||||
try(var logging = PhiLogScope.open(true); InputStream stream = SystemWrites.call(() -> runtime.payloads().open(new PayloadRef(sample.getValueString("payloadRef"), null, 0))))
|
||||
{
|
||||
if("EDIFACT".equals(map.getValueString("standard")))
|
||||
{
|
||||
var interchanges = new EdifactEnvelopeReader(runtime.config().getMaxSegmentsPerTransaction()).read(stream);
|
||||
state.interchanges = interchanges.size();
|
||||
for(var interchange : interchanges)
|
||||
{
|
||||
state.groups += interchange.groups().size();
|
||||
state.envelope(interchange.findings());
|
||||
interchange.groups().forEach(group -> state.envelope(group.findings()));
|
||||
Map<String, List<ValidationFinding>> all = new LinkedHashMap<>();
|
||||
for(var message : interchange.allMessages())
|
||||
{
|
||||
all.put(message.reference(), document(state, map, sample, rules, guide, message, phi));
|
||||
}
|
||||
if(interchange.unb() != null)
|
||||
{
|
||||
var ack = new ContrlBuilder().build(interchange, all, AckLevel.ALL_TRANSACTIONS, "1", map.getValueString("version"));
|
||||
state.ack("CONTRL", phi ? null : new EdifactWriter().write(ack.segments(), interchange.separators(), ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var interchanges = runtime.reader().read(stream);
|
||||
phi = phi || interchanges.stream().flatMap(i -> i.groups().stream()).flatMap(g -> g.transactions().stream()).anyMatch(t -> healthcare(t.st().value(1)));
|
||||
state.interchanges = interchanges.size();
|
||||
for(var interchange : interchanges)
|
||||
{
|
||||
state.groups += interchange.groups().size();
|
||||
state.envelope(interchange.findings());
|
||||
if(!interchange.findings().isEmpty())
|
||||
{
|
||||
var ta1 = runtime.ta1().build(interchange.isa(), interchange.findings());
|
||||
var isa = AckEnvelope.replyIsa(interchange.isa(), "1", java.time.LocalDateTime.now(runtime.config().getClock()));
|
||||
var reply = new com.kingsrook.qbits.edi.x12.X12Interchange(interchange.separators(), isa,
|
||||
com.kingsrook.qbits.edi.x12.X12Segment.of("IEA", 3, "0", isa.value(13)), List.of(), List.of(ta1), List.of());
|
||||
state.ack("TA1", phi ? null : runtime.writer().write(reply));
|
||||
}
|
||||
for(var group : interchange.groups())
|
||||
{
|
||||
state.envelope(group.findings());
|
||||
Map<String, List<ValidationFinding>> all = new LinkedHashMap<>();
|
||||
for(var transaction : group.transactions())
|
||||
{
|
||||
all.put(transaction.st().value(2), document(state, map, sample, rules, guide, transaction, phi));
|
||||
}
|
||||
boolean use999 = phi && group.gs().value(8).startsWith("005010X");
|
||||
var ack = use999 ? runtime.ack999().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001") : runtime.ack997().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001");
|
||||
state.ack(use999 ? "999" : "997", phi ? null : runtime.writer().write(AckEnvelope.wrap(interchange, group, ack, "1", "1", java.time.LocalDateTime.now(runtime.config().getClock()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// Parser, custom transform and expected-JSON diagnostics may contain payload values.
|
||||
throw (new QBadRequestException("Sample could not be previewed; check the document, guide and rules."));
|
||||
}
|
||||
return (object("parse", Map.of("interchanges", state.interchanges, "groups", state.groups, "documents", state.documents),
|
||||
"findings", state.findings, "ack", object("type", state.ackType, "text", phi ? null : state.ackText.toString()),
|
||||
"result", phi ? null : Map.of("records", state.records), "diff", Map.of("againstPreviousRevision", state.previous, "againstExpected", state.expected), "phiSuppressed", phi));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate and map one document, collecting only value-free findings for PHI.
|
||||
*******************************************************************************/
|
||||
private List<ValidationFinding> document(State state, QRecord map, QRecord sample, List<QRecord> rules, Guide guide, EdiDocument document, boolean phi) throws Exception
|
||||
{
|
||||
int index = state.documents++;
|
||||
state.envelope(document.findings());
|
||||
var findings = EdiRuntime.current().validator().validate(document, guide, phi);
|
||||
for(var finding : findings)
|
||||
{
|
||||
state.findings.add(object("snipLevel", finding.snipLevel(), "severity", finding.severity(), "segmentPosition", finding.segmentPosition(),
|
||||
"segmentId", finding.segmentId(), "loopId", finding.loopId(), "elementPosition", finding.elementPosition(),
|
||||
"code", finding.elementPosition() == null ? finding.ak3Code() : finding.ak4Code(), "message", phi ? "Validation finding" : finding.message()));
|
||||
}
|
||||
QRecord mapped;
|
||||
try
|
||||
{
|
||||
mapped = new InboundMapper(EdiRuntime.current().transforms()).map(document, guide, map, rules).record();
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
state.findings.add(object("snipLevel", 0, "severity", "ERROR", "segmentPosition", null, "segmentId", null, "loopId", null,
|
||||
"elementPosition", null, "code", "MAP", "message", "Mapping failed; check the rule paths and transform arguments"));
|
||||
return (findings);
|
||||
}
|
||||
if(!phi)
|
||||
{
|
||||
Map<String, Object> actual = record(mapped);
|
||||
state.records.add(actual);
|
||||
String expected = sample.getValueString("expectedResultJson");
|
||||
if(expected != null)
|
||||
{
|
||||
difference(state.expected, index, JSON.readValue(expected, Object.class), actual);
|
||||
}
|
||||
Integer revision = map.getValueInteger("revision");
|
||||
var previous = EdiRecords.queryEnforced("edi_map", EdiRecords.equal("name", map.getValue("name")));
|
||||
previous.removeIf(r -> !Objects.equals(r.getValue("partnerId"), map.getValue("partnerId"))
|
||||
|| !Objects.equals(r.getValue("standard"), map.getValue("standard")) || !Objects.equals(r.getValue("transactionSet"), map.getValue("transactionSet"))
|
||||
|| !Objects.equals(r.getValue("direction"), map.getValue("direction")) || !Objects.equals(r.getValue("version"), map.getValue("version"))
|
||||
|| !Objects.equals(r.getValue("tableName"), map.getValue("tableName"))
|
||||
|| revision == null || r.getValueInteger("revision") == null || r.getValueInteger("revision") >= revision);
|
||||
previous.sort(Comparator.comparing((QRecord r) -> r.getValueInteger("revision")).reversed());
|
||||
if(!previous.isEmpty() && !containsPhi(previous.getFirst()))
|
||||
{
|
||||
QRecord prior = previous.getFirst();
|
||||
QRecord priorResult = new InboundMapper(EdiRuntime.current().transforms()).map(document, guide(prior, "ORIGINAL"), prior, rules(prior, null)).record();
|
||||
difference(state.previous, index, record(priorResult), actual);
|
||||
}
|
||||
}
|
||||
return (findings);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable record values and recursive associations, without QQQ diagnostics.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> record(QRecord record)
|
||||
{
|
||||
Map<String, Object> associations = new LinkedHashMap<>();
|
||||
if(record.getAssociatedRecords() != null)
|
||||
{
|
||||
record.getAssociatedRecords().forEach((name, children) -> associations.put(name, children.stream().map(MapPreviewService::record).toList()));
|
||||
}
|
||||
return (object("values", record.getValues(), "associations", associations));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare normalized JSON so temporal values and numeric types match wire output.
|
||||
*******************************************************************************/
|
||||
private static void difference(List<Map<String, Object>> result, int index, Object before, Object after)
|
||||
{
|
||||
if(!JSON.valueToTree(before).equals(JSON.valueToTree(after)))
|
||||
{
|
||||
result.add(object("documentIndex", index, "before", before, "after", after));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered objects allowing explicit nulls on the wire.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> object(Object... pairs)
|
||||
{
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
for(int i = 0; i < pairs.length; i += 2)
|
||||
{
|
||||
result.put((String) pairs[i], pairs[i + 1]);
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One invocation's accumulation; never shared across requests.
|
||||
*******************************************************************************/
|
||||
private static class State
|
||||
{
|
||||
private int interchanges;
|
||||
private int groups;
|
||||
private int documents;
|
||||
private final List<Map<String, Object>> findings = new ArrayList<>();
|
||||
private final List<Map<String, Object>> records = new ArrayList<>();
|
||||
private final List<Map<String, Object>> previous = new ArrayList<>();
|
||||
private final List<Map<String, Object>> expected = new ArrayList<>();
|
||||
private String ackType = "NONE";
|
||||
private final StringBuilder ackText = new StringBuilder();
|
||||
|
||||
/*******************************************************************************
|
||||
** Envelope diagnostics carry no raw envelope identifiers.
|
||||
*******************************************************************************/
|
||||
private void envelope(List<EnvelopeFinding> values)
|
||||
{
|
||||
values.forEach(f -> findings.add(object("snipLevel", 1, "severity", "ERROR", "segmentPosition", f.segmentPosition(),
|
||||
"segmentId", null, "loopId", null, "elementPosition", null, "code", f.code(), "message", "Envelope validation finding")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Aggregate acknowledgements in wire order.
|
||||
*******************************************************************************/
|
||||
private void ack(String type, String text)
|
||||
{
|
||||
ackType = "NONE".equals(ackType) || type.equals(ackType) ? type : "MIXED";
|
||||
if(text != null)
|
||||
{
|
||||
ackText.append(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* 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.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.guide.Guide;
|
||||
import com.kingsrook.qbits.edi.guide.GuideSegment;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Static validation of unsaved rules; never runs transforms or writes records.
|
||||
*******************************************************************************/
|
||||
public class MapRuleValidator
|
||||
{
|
||||
private static final Pattern ELEMENT = Pattern.compile("([A-Z][A-Z0-9]{1,2}?)([0-9]{2})(?:-([0-9]+))?");
|
||||
private static final Pattern STEP = Pattern.compile("([^\\[]+)(?:\\[(\\*|[1-9][0-9]*|[^\\[\\]]+=[^\\[\\]]+)])?");
|
||||
|
||||
/*******************************************************************************
|
||||
** Report independent errors without echoing rule literals or default values.
|
||||
*******************************************************************************/
|
||||
public List<String> validate(QRecord rule, QRecord map, Guide guide, TransformRegistry registry)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(rule.getValueInteger("sequence") == null || rule.getValueInteger("sequence") < 1)
|
||||
{
|
||||
errors.add("sequence must be a positive integer");
|
||||
}
|
||||
boolean outbound = "OUTBOUND".equals(map.getValueString("direction"));
|
||||
String ediPath = rule.getValueString("ediPath");
|
||||
String transform = Objects.requireNonNullElse(rule.getValueString("transform"), "").toUpperCase(Locale.ROOT);
|
||||
if(ediPath == null || ediPath.isBlank())
|
||||
{
|
||||
if(outbound || !transform.equals("CONSTANT") && rule.getValueString("defaultValue") == null)
|
||||
{
|
||||
errors.add("ediPath is required");
|
||||
}
|
||||
}
|
||||
else if(!resolves(ediPath, guide))
|
||||
{
|
||||
errors.add("ediPath does not resolve in the guide");
|
||||
}
|
||||
String target = rule.getValueString("qqqPath");
|
||||
QTableMetaData table = QContext.getQInstance().getTable(map.getValueString("tableName"));
|
||||
String[] fields = target == null ? new String[0] : target.split("\\.", -1);
|
||||
for(int i = 0; table != null && i < fields.length - 1; i++)
|
||||
{
|
||||
var associations = table.getAssociations();
|
||||
String name = fields[i];
|
||||
var association = associations == null ? null : associations.stream().filter(a -> a.getName().equals(name)).findFirst().orElse(null);
|
||||
table = association == null ? null : QContext.getQInstance().getTable(association.getAssociatedTableName());
|
||||
}
|
||||
boolean generated = outbound && (target == null || target.isBlank()) && List.of("CONSTANT", "SEQUENCE", "HL").contains(transform);
|
||||
if(!generated && (table == null || fields.length == 0 || !table.getFields().containsKey(fields[fields.length - 1])
|
||||
|| !outbound && fields.length > 2))
|
||||
{
|
||||
errors.add("qqqPath does not resolve in the target table");
|
||||
}
|
||||
if(fields.length > 1 && !"OUTBOUND".equals(map.getValueString("direction")) && (ediPath == null || !ediPath.contains("[*]/")))
|
||||
{
|
||||
errors.add("An inbound association requires a wildcard loop");
|
||||
}
|
||||
Map<String, Object> schema = registry.argumentSchemas().get(transform);
|
||||
if(!transform.isEmpty() && schema == null)
|
||||
{
|
||||
errors.add("Unknown transform");
|
||||
}
|
||||
try
|
||||
{
|
||||
String raw = rule.getValueString("transformArgs");
|
||||
var args = new ObjectMapper().readTree(raw == null || raw.isBlank() ? "{}" : raw);
|
||||
if(!args.isObject() || java.util.stream.StreamSupport.stream(java.util.Spliterators.spliteratorUnknownSize(args.elements(), 0), false).anyMatch(v -> !v.isTextual()))
|
||||
{
|
||||
errors.add("transformArgs must be a JSON object of strings");
|
||||
}
|
||||
else if(schema != null && schema.get("required") instanceof List<?> required)
|
||||
{
|
||||
for(Object key : required)
|
||||
{
|
||||
if(!args.hasNonNull(key.toString()))
|
||||
{
|
||||
errors.add("Missing required transform argument: " + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
errors.add("transformArgs must be a JSON object of strings");
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve every joined element path, including selectors and composite positions.
|
||||
*******************************************************************************/
|
||||
private boolean resolves(String path, Guide guide)
|
||||
{
|
||||
for(String part : path.split("\\+(?![^\\[]*])", -1))
|
||||
{
|
||||
List<GuideSegment> scope = guide.segments();
|
||||
String[] steps = part.trim().split("/", -1);
|
||||
for(int index = 0; index < steps.length; index++)
|
||||
{
|
||||
var step = STEP.matcher(steps[index]);
|
||||
if(!step.matches())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String name = step.group(1);
|
||||
String qualifier = step.group(2);
|
||||
var element = ELEMENT.matcher(name);
|
||||
if(index == steps.length - 1)
|
||||
{
|
||||
if(!element.matches() || !element(scope, name))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
scope = scope.stream().filter(s -> s.segmentId().equals(element.group(1))).toList();
|
||||
}
|
||||
else
|
||||
{
|
||||
List<GuideSegment> current = scope;
|
||||
scope = current.stream().filter(s -> s.segmentId().equals(name) || inLoop(s, name, guide, 0)).toList();
|
||||
if(scope.isEmpty())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
if(qualifier != null && qualifier.contains("=") && !element(scope, qualifier.substring(0, qualifier.indexOf('='))))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ancestor scope matters when different loops contain the same segment.
|
||||
*******************************************************************************/
|
||||
private boolean inLoop(GuideSegment segment, String loop, Guide guide, int depth)
|
||||
{
|
||||
if(loop.equals(segment.loopId()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(segment.parentLoopId() == null || depth > guide.segments().size())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (loop.equals(segment.parentLoopId()) || guide.segments().stream().filter(s -> segment.parentLoopId().equals(s.loopId()))
|
||||
.anyMatch(parent -> inLoop(parent, loop, guide, depth + 1)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match an element and its optional composite component.
|
||||
*******************************************************************************/
|
||||
private boolean element(List<GuideSegment> segments, String path)
|
||||
{
|
||||
var match = ELEMENT.matcher(path);
|
||||
if(!match.matches())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int position = Integer.parseInt(match.group(2));
|
||||
Integer component;
|
||||
try
|
||||
{
|
||||
component = match.group(3) == null ? null : Integer.valueOf(match.group(3));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (segments.stream().filter(s -> s.segmentId().equals(match.group(1))).flatMap(s -> s.elements().stream())
|
||||
.anyMatch(e -> e.position() == position && (component == null || component.equals(e.componentPosition()))));
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
public class TransformRegistry
|
||||
{
|
||||
private final Map<String, Transform> transforms = new ConcurrentHashMap<>();
|
||||
private final Map<String, Map<String, Object>> argumentSchemas = new ConcurrentHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform a selected path's values in the service session.
|
||||
@@ -118,11 +119,59 @@ public class TransformRegistry
|
||||
** Register or replace a named host transform.
|
||||
*******************************************************************************/
|
||||
public TransformRegistry register(String name, Transform transform)
|
||||
{
|
||||
return (register(name, transform, schema(name)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host transforms publish their argument contract alongside executable behavior.
|
||||
*******************************************************************************/
|
||||
public TransformRegistry register(String name, Transform transform, Map<String, Object> argumentSchema)
|
||||
{
|
||||
transforms.put(name.toUpperCase(Locale.ROOT), transform);
|
||||
argumentSchemas.put(name.toUpperCase(Locale.ROOT), Map.copyOf(argumentSchema));
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sorted snapshot for mapper clients and static validation.
|
||||
*******************************************************************************/
|
||||
public Map<String, Map<String, Object>> argumentSchemas()
|
||||
{
|
||||
return (java.util.Collections.unmodifiableMap(new java.util.TreeMap<>(argumentSchemas)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Built-ins accept strings, matching the persisted transformArgs representation.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> schema(String name)
|
||||
{
|
||||
List<String> keys = switch(name.toUpperCase(Locale.ROOT))
|
||||
{
|
||||
case "CONSTANT" -> List.of("value");
|
||||
case "LOOKUP" -> List.of("table", "key", "result");
|
||||
case "DATETIME" -> List.of("time", "timezone");
|
||||
case "DATEPART", "TIMEPART" -> List.of("timezone");
|
||||
case "DECIMAL" -> List.of("scale");
|
||||
case "CONCAT" -> List.of("separator");
|
||||
case "SPLIT" -> List.of("separator", "index");
|
||||
case "SUBSTRING" -> List.of("start", "end");
|
||||
case "UNIT" -> List.of("factor", "divisor", "scale");
|
||||
case "SEQUENCE" -> List.of("start");
|
||||
case "HL" -> List.of("code");
|
||||
default -> List.of();
|
||||
};
|
||||
Map<String, Object> properties = new java.util.TreeMap<>();
|
||||
keys.forEach(key -> properties.put(key, Map.of("type", "string")));
|
||||
List<String> required = switch(name.toUpperCase(Locale.ROOT))
|
||||
{
|
||||
case "CONSTANT" -> List.of("value");
|
||||
case "LOOKUP" -> List.of("table", "key");
|
||||
default -> List.of();
|
||||
};
|
||||
return (Map.of("type", "object", "properties", properties, "required", required, "additionalProperties", Map.of("type", "string")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute a closed registry entry; unknown names fail explicitly.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -27,7 +27,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 form filters; no dependency on later dependency-behavior metadata.
|
||||
** RC.6 form filters; no dependency on later dependency-behavior metadata.
|
||||
*******************************************************************************/
|
||||
public final class EdiContingencies
|
||||
{
|
||||
@@ -75,7 +75,9 @@ public final class EdiContingencies
|
||||
}
|
||||
if(filter != null)
|
||||
{
|
||||
field.withPossibleValueSourceFilter(filter);
|
||||
// Core appends its search criteria to the root; keep them ANDed with partner scope.
|
||||
field.withPossibleValueSourceFilter(filter.getBooleanOperator() == QQueryFilter.BooleanOperator.OR
|
||||
? new QQueryFilter().withSubFilter(filter) : filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,10 @@ import com.kingsrook.qbits.edi.model.EdiPayload;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransaction;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransactionError;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.security.EdiClassifications;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -234,9 +234,10 @@ public final class EdiTables
|
||||
.withPermissionRules(new QPermissionRules().withPermissionBaseName("edi." + name.substring(4)).withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(readOnly || name.equals("edi_map_sample") ? AuditLevel.RECORD : AuditLevel.FIELD));
|
||||
QRecordEntity.registerTable(entity, table);
|
||||
AiTableMetaData.ofOrWithNew(table).withClassification(DataClassification.CONFIDENTIAL);
|
||||
if(List.of("edi_payload", "edi_certificate", "edi_inbox").contains(name))
|
||||
{
|
||||
table.withSupplementalMetaData(new AiTableMetaData().withExposure(AiExposure.NONE));
|
||||
AiTableMetaData.ofOrWithNew(table).withExposure(AiExposure.NONE);
|
||||
}
|
||||
if(name.equals("edi_exception"))
|
||||
{
|
||||
@@ -253,13 +254,14 @@ public final class EdiTables
|
||||
}
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String classification = List.of("phiBusinessKey", "fileName", "ciphertext", "expectedResultJson", "valueExcerpt").contains(field.getName()) ? "PHI"
|
||||
: List.of("contactName", "contactEmail", "contactPhone").contains(field.getName()) ? "PII" : "CONFIDENTIAL";
|
||||
field.withSupplementalMetaData(new EdiClassifications(classification));
|
||||
if(classification.equals("PHI"))
|
||||
DataClassification classification = List.of("phiBusinessKey", "fileName", "ciphertext", "expectedResultJson", "valueExcerpt").contains(field.getName()) ? DataClassification.PHI
|
||||
: List.of("contactName", "contactEmail", "contactPhone").contains(field.getName()) ? DataClassification.PII : DataClassification.CONFIDENTIAL;
|
||||
AiFieldMetaData metadata = new AiFieldMetaData().withClassification(classification);
|
||||
if(classification == DataClassification.PHI)
|
||||
{
|
||||
field.withSupplementalMetaData(new AiFieldMetaData().withExposure(AiExposure.NONE));
|
||||
metadata.withExposure(AiExposure.NONE);
|
||||
}
|
||||
field.withSupplementalMetaData(metadata);
|
||||
}
|
||||
EdiTablePresentation.apply(table);
|
||||
EdiRecordLabels.configure(table);
|
||||
|
||||
@@ -32,6 +32,9 @@ import com.kingsrook.qbits.edi.map.MapPreview;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
@@ -134,6 +137,8 @@ public final class EdiProcesses
|
||||
.withName(table + "_" + operation).withLabel(label).withTableName(table).withIsHidden(true).withMinInputRecords(1).withPermissionRules(permission(permission));
|
||||
if(operation.equals("preview"))
|
||||
{
|
||||
process.getPermissionRules().withCustomPermissionChecker(
|
||||
com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker.build("edi.map.preview"));
|
||||
if(table.equals("edi_map"))
|
||||
{
|
||||
process.getFrontendStep("review").withFormField(new QFieldMetaData("sampleId", QFieldType.INTEGER)
|
||||
@@ -316,13 +321,25 @@ public final class EdiProcesses
|
||||
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("status", operation.equals("assign") ? "IN_PROGRESS" : operation.equals("ignore") ? "IGNORED" : "RESOLVED");
|
||||
if(operation.equals("assign"))
|
||||
{
|
||||
update.withValue("assignedToUserId", input.getValue("assignedToUserId"));
|
||||
String assignee = input.getValueString("assignedToUserId");
|
||||
if(assignee == null || assignee.isBlank())
|
||||
{
|
||||
throw new IllegalArgumentException("An assignee is required");
|
||||
}
|
||||
update.withValue("assignedToUserId", assignee);
|
||||
}
|
||||
else
|
||||
{
|
||||
update.withValue("resolvedBy", user).withValue("resolvedAt", EdiRuntime.current().config().getClock().instant());
|
||||
}
|
||||
SystemWrites.update(table, update);
|
||||
if(operation.equals("assign"))
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("EDI exception assigned")
|
||||
.withBody("An EDI exception has been assigned to you.")
|
||||
.withAudience(NotificationAudience.user(update.getValueString("assignedToUserId")))
|
||||
.withSourceName("edi").withSourceTableName(table).withSourceRecordId(row.getValueString("id")));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -61,7 +61,11 @@ public class OutboundProcesses implements BackendStep
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(OutboundProcesses.class)));
|
||||
if(config.getSchedulerName() != null && (seconds != null || cron != null))
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(seconds).withCronExpression(cron).withCronTimeZoneId("UTC"));
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// core validation refuses a cron time zone on a repeat schedule //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(seconds).withCronExpression(cron)
|
||||
.withCronTimeZoneId(cron == null ? null : "UTC"));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* 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.security;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6). Field classifications on RC.5.
|
||||
*******************************************************************************/
|
||||
public class EdiClassifications extends QSupplementalFieldMetaData
|
||||
{
|
||||
private final String classification;
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain classification as metadata until the core enum is available.
|
||||
*******************************************************************************/
|
||||
public EdiClassifications(String classification)
|
||||
{
|
||||
this.classification = classification;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable supplemental metadata key.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("edi.classification");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the classification without requiring an RC.6 class.
|
||||
*******************************************************************************/
|
||||
public String getClassification()
|
||||
{
|
||||
return (classification);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bridge when a host supplies the newer field classification API.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void enrich(QInstance instance, QFieldMetaData field)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.actions.ai.DataClassification");
|
||||
Object value = type.getField(classification).get(null);
|
||||
field.getClass().getMethod("setDataClassification", type).invoke(field, value);
|
||||
}
|
||||
catch(ClassNotFoundException e)
|
||||
{
|
||||
// RC.5 keeps the explicit supplemental classification below.
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw new IllegalStateException("Update the EDI classification bridge for this core version", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,8 @@ import org.apache.logging.log4j.message.Message;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6).
|
||||
** RC.5 logs query criteria verbatim. Suppress synchronous diagnostic logging only
|
||||
** STAND-IN(core=QRun-IO/qqq#1147, removeBy=4.1.0-RC.7).
|
||||
** RC.6 logs query criteria verbatim. Suppress synchronous diagnostic logging only
|
||||
** while PHI mapping/application runs; value-free EDI events remain in the database.
|
||||
** This does not replace host masking or protection of host code outside this scope.
|
||||
*******************************************************************************/
|
||||
@@ -60,7 +60,7 @@ public final class PhiLogScope implements AutoCloseable
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a scope around code that may pass clinical values to RC.5 core actions.
|
||||
** Open a scope around code that may pass clinical values to RC.6 core actions.
|
||||
*******************************************************************************/
|
||||
public static PhiLogScope open(boolean enabled)
|
||||
{
|
||||
@@ -74,7 +74,7 @@ public final class PhiLogScope implements AutoCloseable
|
||||
{
|
||||
if(!(LogManager.getContext(false) instanceof LoggerContext context))
|
||||
{
|
||||
throw new IllegalStateException("PHI processing requires the RC.5 Log4j protection bridge");
|
||||
throw new IllegalStateException("PHI processing requires the RC.6 Log4j protection bridge");
|
||||
}
|
||||
Configuration configuration = context.getConfiguration();
|
||||
if(!INSTALLED.containsKey(configuration))
|
||||
|
||||
@@ -60,6 +60,17 @@ public final class EdiRecords
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(com.kingsrook.qqq.backend.core.context.QContext.getQBackendTransaction())).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** UI-facing reads enforce permissions even when the host disables default enforcement.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> queryEnforced(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true)
|
||||
.withTransaction(com.kingsrook.qqq.backend.core.context.QContext.getQBackendTransaction());
|
||||
input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match one field by exact value.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
components:
|
||||
schemas:
|
||||
EdiAckV1:
|
||||
description: "Concatenated acknowledgement previews; type is 997, 999, TA1,\
|
||||
\ CONTRL, NONE or MIXED. text is null for PHI."
|
||||
properties:
|
||||
text:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiAssociationV1:
|
||||
description: "Direct QQQ association and its target fields."
|
||||
properties:
|
||||
fields:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiFieldV1"
|
||||
type: "array"
|
||||
label:
|
||||
nullable: true
|
||||
type: "string"
|
||||
name:
|
||||
nullable: true
|
||||
type: "string"
|
||||
table:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiDiffV1:
|
||||
description: "Only differing records are returned. Both lists are empty for\
|
||||
\ PHI."
|
||||
properties:
|
||||
againstExpected:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiDifferenceV1"
|
||||
type: "array"
|
||||
againstPreviousRevision:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiDifferenceV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiDifferenceV1:
|
||||
description: "One document comparison; documentIndex is zero based."
|
||||
properties:
|
||||
after:
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
type: "object"
|
||||
before:
|
||||
description: "Expected JSON or a previous mapped record."
|
||||
nullable: true
|
||||
documentIndex:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type: "object"
|
||||
EdiElementV1:
|
||||
description: "Element or composite component; codes contain at most 200 active\
|
||||
\ codes in lexical order."
|
||||
properties:
|
||||
codeListId:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
codes:
|
||||
items:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "array"
|
||||
component:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
id:
|
||||
nullable: true
|
||||
type: "string"
|
||||
maxLength:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
minLength:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
name:
|
||||
nullable: true
|
||||
type: "string"
|
||||
position:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type:
|
||||
nullable: true
|
||||
type: "string"
|
||||
usage:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiErrorV1:
|
||||
description: "Core middleware error body."
|
||||
properties:
|
||||
error:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiFieldV1:
|
||||
description: "QQQ target field metadata."
|
||||
properties:
|
||||
label:
|
||||
nullable: true
|
||||
type: "string"
|
||||
name:
|
||||
nullable: true
|
||||
type: "string"
|
||||
possibleValueSourceName:
|
||||
nullable: true
|
||||
type: "string"
|
||||
required:
|
||||
type: "boolean"
|
||||
type:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiFindingV1:
|
||||
description: "Value-free diagnostic. PHI messages are generic; only positions\
|
||||
\ and codes are retained."
|
||||
properties:
|
||||
code:
|
||||
nullable: true
|
||||
type: "string"
|
||||
elementPosition:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
loopId:
|
||||
nullable: true
|
||||
type: "string"
|
||||
message:
|
||||
nullable: true
|
||||
type: "string"
|
||||
segmentId:
|
||||
nullable: true
|
||||
type: "string"
|
||||
segmentPosition:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
severity:
|
||||
nullable: true
|
||||
type: "string"
|
||||
snipLevel:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type: "object"
|
||||
EdiGuideTreeV1:
|
||||
description: "Ordered guide tree. Root segments are outside loops; nested loops\
|
||||
\ retain parent scope."
|
||||
properties:
|
||||
loops:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiLoopV1"
|
||||
type: "array"
|
||||
segments:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiSegmentV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiLoopV1:
|
||||
description: "Guide loop, nested within its parent; repeat is the maximum occurrence\
|
||||
\ count."
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: "string"
|
||||
loops:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiLoopV1"
|
||||
type: "array"
|
||||
repeat:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
segments:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiSegmentV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiParseV1:
|
||||
description: "Counts of wire interchanges, groups and documents."
|
||||
properties:
|
||||
documents:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
groups:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
interchanges:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type: "object"
|
||||
EdiPreviewRequestV1:
|
||||
description: "mapId is required. Supply exactly one of sampleId or sampleBase64.\
|
||||
\ Uploads are at most 5 MiB decoded, stored encrypted. ruleOverrides replaces\
|
||||
\ the whole rule set in memory. CURRENT (default) uses the newest active guide\
|
||||
\ in the pinned guide family; ORIGINAL uses the pinned guide."
|
||||
properties:
|
||||
guideRevision:
|
||||
enum:
|
||||
- "CURRENT"
|
||||
- "ORIGINAL"
|
||||
nullable: true
|
||||
type: "string"
|
||||
mapId:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
ruleOverrides:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRuleV1"
|
||||
nullable: true
|
||||
type: "array"
|
||||
sampleBase64:
|
||||
format: "byte"
|
||||
maxLength: 6990508
|
||||
nullable: true
|
||||
type: "string"
|
||||
sampleId:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
sampleName:
|
||||
maxLength: 255
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiPreviewV1:
|
||||
description: "Parse, validate, acknowledge and map without target writes. PHI\
|
||||
\ suppresses result, ack text, and both diffs."
|
||||
properties:
|
||||
ack:
|
||||
$ref: "#/components/schemas/EdiAckV1"
|
||||
diff:
|
||||
$ref: "#/components/schemas/EdiDiffV1"
|
||||
findings:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiFindingV1"
|
||||
type: "array"
|
||||
parse:
|
||||
$ref: "#/components/schemas/EdiParseV1"
|
||||
phiSuppressed:
|
||||
type: "boolean"
|
||||
result:
|
||||
$ref: "#/components/schemas/EdiResultV1"
|
||||
nullable: true
|
||||
type: "object"
|
||||
EdiRecordV1:
|
||||
description: "Field values and recursively associated records. Values use the\
|
||||
\ target table field types."
|
||||
properties:
|
||||
associations:
|
||||
additionalProperties:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRecordV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
values:
|
||||
additionalProperties: true
|
||||
type: "object"
|
||||
type: "object"
|
||||
EdiResultV1:
|
||||
description: "Mapped records in document order; no target writes."
|
||||
properties:
|
||||
records:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRecordV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiRuleErrorsV1:
|
||||
description: "Correlate errors using zero-based index, optional id and sequence."
|
||||
properties:
|
||||
errors:
|
||||
items:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "array"
|
||||
id:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
index:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
sequence:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type: "object"
|
||||
EdiRuleV1:
|
||||
description: "Map rule. id is optional for unsaved rules. transformArgs is a\
|
||||
\ JSON object encoded as a string, as in core table CRUD."
|
||||
properties:
|
||||
condition:
|
||||
nullable: true
|
||||
type: "string"
|
||||
defaultValue:
|
||||
nullable: true
|
||||
type: "string"
|
||||
ediPath:
|
||||
nullable: true
|
||||
type: "string"
|
||||
id:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
qqqPath:
|
||||
nullable: true
|
||||
type: "string"
|
||||
required:
|
||||
nullable: true
|
||||
type: "boolean"
|
||||
sequence:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
transform:
|
||||
nullable: true
|
||||
type: "string"
|
||||
transformArgs:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiSegmentV1:
|
||||
description: "Segment definition in guide sequence order."
|
||||
properties:
|
||||
elements:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiElementV1"
|
||||
type: "array"
|
||||
id:
|
||||
nullable: true
|
||||
type: "string"
|
||||
max:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
min:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
name:
|
||||
nullable: true
|
||||
type: "string"
|
||||
position:
|
||||
nullable: true
|
||||
type: "string"
|
||||
usage:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiTargetV1:
|
||||
description: "Target metadata visible to the caller, current rules and registered\
|
||||
\ transforms."
|
||||
properties:
|
||||
associations:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiAssociationV1"
|
||||
type: "array"
|
||||
fields:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiFieldV1"
|
||||
type: "array"
|
||||
rules:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRuleV1"
|
||||
type: "array"
|
||||
table:
|
||||
nullable: true
|
||||
type: "string"
|
||||
transforms:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiTransformV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiTransformV1:
|
||||
description: "Registered transform and JSON Schema describing its string-valued\
|
||||
\ arguments."
|
||||
properties:
|
||||
argumentSchema:
|
||||
additionalProperties: true
|
||||
description: "JSON Schema: type, properties, required and additionalProperties.\
|
||||
\ All transform arguments are strings."
|
||||
type: "object"
|
||||
name:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiValidateRequestV1:
|
||||
description: "rules is required and may be empty; rules are never persisted."
|
||||
properties:
|
||||
rules:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRuleV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiValidationV1:
|
||||
description: "One result per submitted rule, including rules with no errors."
|
||||
properties:
|
||||
rules:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRuleErrorsV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
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"
|
||||
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."
|
||||
parameters:
|
||||
- description: "Positive record identifier."
|
||||
in: "path"
|
||||
name: "guideId"
|
||||
required: true
|
||||
schema:
|
||||
type: "integer"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiGuideTreeV1"
|
||||
description: "Successful Response"
|
||||
413:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Read the guide tree"
|
||||
/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."
|
||||
parameters:
|
||||
- description: "Positive record identifier."
|
||||
in: "path"
|
||||
name: "mapId"
|
||||
required: true
|
||||
schema:
|
||||
type: "integer"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiTargetV1"
|
||||
description: "Successful Response"
|
||||
413:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Read target metadata and map rules"
|
||||
/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."
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiPreviewRequestV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiPreviewV1"
|
||||
description: "Successful Response"
|
||||
413:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Preview an encrypted sample with optional unsaved rules"
|
||||
/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."
|
||||
parameters:
|
||||
- description: "Positive record identifier."
|
||||
in: "path"
|
||||
name: "mapId"
|
||||
required: true
|
||||
schema:
|
||||
type: "integer"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiValidateRequestV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiValidationV1"
|
||||
description: "Successful Response"
|
||||
413:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiErrorV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Validate unsaved map rules"
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueSearchFilterUseCase;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
@@ -39,7 +40,7 @@ import org.junit.jupiter.params.provider.CsvSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Contingent metadata and real RC.5 possible-value searches with form values.
|
||||
** Contingent metadata and real RC.6 possible-value searches with form values.
|
||||
*******************************************************************************/
|
||||
class ContingenciesTest
|
||||
{
|
||||
@@ -123,7 +124,7 @@ class ContingenciesTest
|
||||
{
|
||||
var field = instance.getTable(pair[0]).getField(pair[1]);
|
||||
QQueryFilter interpreted = field.getPossibleValueSourceFilter().clone();
|
||||
// RC.5 middleware interprets the field filter before calling the search action.
|
||||
// RC.6 middleware interprets the field filter before calling the search action.
|
||||
interpreted.interpretValues(Map.of("partnerId", 1));
|
||||
var result = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(field.getPossibleValueSourceName())
|
||||
.withDefaultQueryFilter(interpreted).withOtherValues(Map.of("partnerId", 1)).withSearchTerm("").withLimit(100));
|
||||
@@ -133,6 +134,41 @@ class ContingenciesTest
|
||||
assertThat(instance.getTable("edi_exception").getField("assignedToUserId").getPossibleValueSourceFilter()).isNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 lacks dependency behavior metadata; shared transports still work with no partner.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sharedTransportBeforeAndAfterPartnerSelection() throws Exception
|
||||
{
|
||||
var instance = H2Support.create();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
for(Integer partner : java.util.Arrays.asList(null, 1, 2))
|
||||
{
|
||||
SystemWrites.insert("edi_transport", new QRecord().withValue("name", "van-" + partner).withValue("partnerId", partner)
|
||||
.withValue("type", "GATEWAY").withValue("direction", "BOTH").withValue("gatewayEndpointName", "synthetic-outbox"));
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
for(var values : List.of(Map.<String, java.io.Serializable>of(), Map.<String, java.io.Serializable>of("partnerId", 1)))
|
||||
{
|
||||
var field = instance.getTable("edi_agreement").getField("outboundTransportId");
|
||||
var filter = field.getPossibleValueSourceFilter().clone();
|
||||
filter.interpretValues(Map.of("input", values), PossibleValueSearchFilterUseCase.FORM);
|
||||
var result = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
|
||||
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withDefaultQueryFilter(filter)
|
||||
.withOtherValues(values).withSearchTerm("").withLimit(100));
|
||||
assertThat(result.getResults()).extracting(value -> value.getId().toString()).contains("1").doesNotContain("3");
|
||||
assertThat(result.getResults()).hasSize(values.isEmpty() ? 1 : 2);
|
||||
filter = field.getPossibleValueSourceFilter().clone();
|
||||
filter.interpretValues(Map.of("input", values), PossibleValueSearchFilterUseCase.FORM);
|
||||
var unrelated = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
|
||||
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withDefaultQueryFilter(filter)
|
||||
.withOtherValues(values).withSearchTerm("van-2").withLimit(100));
|
||||
assertThat(unrelated.getResults()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Test fixture operation.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qbits.edi.api.EdiApi;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Generated extension fragment is the exact versioned contract shipped to clients.
|
||||
*******************************************************************************/
|
||||
class EdiApiContractTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Use RC.6's real helper, including compatibility comparison against this first baseline.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contract() throws Exception
|
||||
{
|
||||
var version = new MiddlewareVersionV1();
|
||||
var extension = EdiApi.extension();
|
||||
String generated = UiApiContract.generateExtensionYaml(version, extension);
|
||||
Path path = Path.of("src/main/resources/openapi/qqq-v1/edi.yaml").toAbsolutePath();
|
||||
if(Boolean.getBoolean("edi.openapi.update"))
|
||||
{
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, generated);
|
||||
}
|
||||
String committed = Files.readString(path);
|
||||
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.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");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer registers the extension and all metadata validates with RC.6.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registration() throws Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
var instance = H2Support.create();
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).getExtensions()).hasSize(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.context.QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
/*
|
||||
* 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.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.api.EdiApiService;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadKind;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinImplementation;
|
||||
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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Real HTTP against strict-mode QQQ, real SQL, real encrypted samples and caller sessions.
|
||||
*******************************************************************************/
|
||||
class EdiApiHttpTest
|
||||
{
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private QApplicationJavalinServer server;
|
||||
private HttpClient client;
|
||||
private QInstance instance;
|
||||
private QRecord map;
|
||||
private QRecord sample;
|
||||
private String baseUrl;
|
||||
private String suffix;
|
||||
|
||||
/*******************************************************************************
|
||||
** The existing synthetic vertical-slice fixture supplies tables, guide and map.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void start() throws Exception
|
||||
{
|
||||
new InboundTest().setup("004010");
|
||||
instance = QContext.getQInstance();
|
||||
map = EdiRecords.query("edi_map", new QQueryFilter()).getFirst();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_map", new QRecord().withValue("id", map.getValue("id")).withValue("partnerId", 1));
|
||||
SystemWrites.update("edi_guide", new QRecord().withValue("id", map.getValue("guideId")).withValue("partnerId", 1));
|
||||
return (null);
|
||||
});
|
||||
map.withValue("partnerId", 1);
|
||||
sample = saveSample(Synthetic850.generate(17, "004010", com.kingsrook.qbits.edi.x12.X12Separators.DEFAULT), map.getValueInteger("id"));
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(Sessions.class)));
|
||||
int port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
baseUrl = "http://127.0.0.1:" + port + "/qqq/v1/qbit/edi";
|
||||
suffix = "-" + UUID.randomUUID();
|
||||
client = HttpClient.newHttpClient();
|
||||
server = new QApplicationJavalinServer(new AbstractQQQApplication()
|
||||
{
|
||||
@Override
|
||||
public QInstance defineQInstance()
|
||||
{
|
||||
return (instance);
|
||||
}
|
||||
}).withPort(port).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
|
||||
.withJavalinConfigCustomizer(config -> config.http.maxRequestSize = 8 * 1024 * 1024);
|
||||
server.start();
|
||||
QContext.init(instance, new QSystemUserSession(instance));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Release server globals and thread context.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void stop()
|
||||
{
|
||||
if(server != null)
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
if(client != null)
|
||||
{
|
||||
client.close();
|
||||
}
|
||||
QJavalinImplementation.setSessionCookieHttpOnly(false);
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary users receive selected reads; none receives raw-payload access.
|
||||
*******************************************************************************/
|
||||
public static class Sessions implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
String role = session.getUuid().split("-")[0];
|
||||
session.withUser(new QUser().withIdReference(role));
|
||||
session.withSecurityKeyValue("edi_partner", 1);
|
||||
if(!role.equals("denied"))
|
||||
{
|
||||
for(String table : instance.getTables().keySet())
|
||||
{
|
||||
if(!table.equals("edi_payload") && !table.equals("edi_preview_download")
|
||||
&& !(role.equals("noguide") && table.equals("edi_guide"))
|
||||
&& !(role.equals("norules") && table.equals("edi_map_rule"))
|
||||
&& !(role.equals("notarget") && table.equals("sample_order")))
|
||||
{
|
||||
String permission = table.startsWith("edi_") ? "edi." + table.substring(4) + ".read"
|
||||
: table.equals("sample_order") ? "sample.order.read" : "sample.line.read";
|
||||
session.withPermissions(permission);
|
||||
}
|
||||
}
|
||||
if(!role.equals("reader"))
|
||||
{
|
||||
session.withPermissions("edi.map.preview");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transport helper retains the exact response for egress scans.
|
||||
*******************************************************************************/
|
||||
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()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assert success before parsing the wire JSON.
|
||||
*******************************************************************************/
|
||||
private JsonNode ok(String method, String path, Object body) throws Exception
|
||||
{
|
||||
var response = request(method, path, body, "user");
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
return (JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An authorized fixture writer archives synthetic bytes only.
|
||||
*******************************************************************************/
|
||||
private QRecord saveSample(String text, Integer mapId) throws Exception
|
||||
{
|
||||
return (SystemWrites.call(() ->
|
||||
{
|
||||
var ref = EdiRuntime.current().payloads().put(PayloadKind.SAMPLE, InboundTest.bytes(text));
|
||||
return (EdiRecords.insert("edi_map_sample", new QRecord().withValue("name", "Synthetic HTTP sample").withValue("mapId", mapId).withValue("payloadRef", ref.value())));
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared stored-sample request.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> preview()
|
||||
{
|
||||
return (Map.of("mapId", map.getValueInteger("id"), "sampleId", sample.getValueInteger("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Guide structure and target schema include associations, rules and argument contracts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void guideAndTarget() throws Exception
|
||||
{
|
||||
var tree = ok("GET", "/guides/" + map.getValue("guideId") + "/tree", null);
|
||||
assertThat(tree.get("loops")).isNotEmpty();
|
||||
assertThat(tree.toString()).contains("\"id\":\"BEG\"", "\"position\":\"0030\"", "codeListId", "minLength");
|
||||
var target = ok("GET", "/maps/" + map.getValue("id") + "/target", null);
|
||||
assertThat(target.get("table").asText()).isEqualTo("sample_order");
|
||||
assertThat(target.get("fields").toString()).contains("poNumber", "possibleValueSourceName");
|
||||
assertThat(target.get("associations").toString()).contains("lines", "sample_order_line", "quantity");
|
||||
assertThat(target.get("rules")).hasSize(5);
|
||||
assertThat(target.get("transforms").toString()).contains("LOOKUP", "argumentSchema", "required", "table", "key");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Each operation returns real 403 responses for denied table or action permissions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissions() throws Exception
|
||||
{
|
||||
String target = "/maps/" + map.getValue("id") + "/target";
|
||||
String validate = "/maps/" + map.getValue("id") + "/validate";
|
||||
assertThat(request("GET", "/guides/" + map.getValue("guideId") + "/tree", null, "denied").statusCode()).isEqualTo(403);
|
||||
assertThat(request("GET", target, null, "denied").statusCode()).isEqualTo(403);
|
||||
assertThat(request("POST", validate, Map.of("rules", List.of()), "denied").statusCode()).isEqualTo(403);
|
||||
assertThat(request("POST", "/sandbox/preview", preview(), "denied").statusCode()).isEqualTo(403);
|
||||
assertThat(request("POST", "/sandbox/preview", preview(), "reader").statusCode()).isEqualTo(403);
|
||||
assertThat(request("GET", target, null, "notarget").statusCode()).isEqualTo(403);
|
||||
assertThat(request("GET", target, null, "norules").statusCode()).isEqualTo(403);
|
||||
assertThat(request("GET", "/guides/" + map.getValue("guideId") + "/tree", null, "noguide").statusCode()).isEqualTo(403);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview returns the mapped structure and ack while leaving target tables untouched.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void storedPreview() throws Exception
|
||||
{
|
||||
var result = ok("POST", "/sandbox/preview", preview());
|
||||
assertThat(result.at("/parse/interchanges").asInt()).isEqualTo(1);
|
||||
assertThat(result.at("/parse/groups").asInt()).isEqualTo(1);
|
||||
assertThat(result.at("/parse/documents").asInt()).isEqualTo(1);
|
||||
assertThat(result.at("/result/records/0/values/poNumber").asText()).isNotBlank();
|
||||
assertThat(result.at("/result/records/0/associations/lines")).hasSize(1);
|
||||
assertThat(result.at("/ack/type").asText()).isEqualTo("997");
|
||||
assertThat(result.at("/ack/text").asText()).contains("AK9");
|
||||
assertThat(result.get("phiSuppressed").asBoolean()).isFalse();
|
||||
assertThat(EdiRecords.query("sample_order", new QQueryFilter())).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unsaved overrides replace rules in memory and custom transforms see the caller.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unsavedOverridesAndCallerSession() throws Exception
|
||||
{
|
||||
EdiRuntime.current().transforms().register("SESSION", (values, args) -> QContext.getQSession().getUser().getIdReference(),
|
||||
Map.of("type", "object", "properties", Map.of(), "required", List.of()));
|
||||
var rule = Map.of("sequence", 1, "ediPath", "BEG03", "qqqPath", "poNumber", "transform", "SESSION");
|
||||
var body = new java.util.LinkedHashMap<>(preview());
|
||||
body.put("ruleOverrides", List.of(rule));
|
||||
var result = ok("POST", "/sandbox/preview", body);
|
||||
assertThat(result.at("/result/records/0/values/poNumber").asText()).isEqualTo("user");
|
||||
assertThat(EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")))).hasSize(5);
|
||||
assertThat(ok("POST", "/sandbox/preview", preview()).at("/result/records/0/values/poNumber").asText()).isNotEqualTo("user");
|
||||
body.put("ruleOverrides", List.of());
|
||||
assertThat(ok("POST", "/sandbox/preview", body).at("/result/records/0/values").size()).isZero();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Base64 uploads become encrypted samples, with no plaintext in the payload table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void upload() throws Exception
|
||||
{
|
||||
String raw = Synthetic850.generate(18, "004010", com.kingsrook.qbits.edi.x12.X12Separators.DEFAULT);
|
||||
int count = EdiRecords.query("edi_map_sample", new QQueryFilter()).size();
|
||||
var result = ok("POST", "/sandbox/preview", Map.of("mapId", map.getValueInteger("id"), "sampleBase64",
|
||||
Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.US_ASCII)), "sampleName", "Uploaded sample"));
|
||||
assertThat(result.at("/parse/documents").asInt()).isEqualTo(1);
|
||||
var samples = EdiRecords.query("edi_map_sample", new QQueryFilter());
|
||||
assertThat(samples).hasSize(count + 1);
|
||||
var uploaded = samples.stream().filter(r -> "Uploaded sample".equals(r.getValueString("name"))).findFirst().orElseThrow();
|
||||
try(var stream = EdiRuntime.current().payloads().open(new PayloadRef(uploaded.getValueString("payloadRef"), null, 0)))
|
||||
{
|
||||
assertThat(new String(stream.readAllBytes(), StandardCharsets.US_ASCII)).isEqualTo(raw);
|
||||
}
|
||||
for(var payload : EdiRecords.query("edi_payload", new QQueryFilter()))
|
||||
{
|
||||
assertThat(new String((byte[]) payload.getValue("ciphertext"), StandardCharsets.ISO_8859_1)).doesNotContain("SYNTH-1", raw);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject oversized decoded bytes as well as encoded lengths before persistence.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void uploadLimitAndBadRequests() throws Exception
|
||||
{
|
||||
int count = EdiRecords.query("edi_map_sample", new QQueryFilter()).size();
|
||||
for(int size : List.of(EdiApiService.MAX_SAMPLE_BYTES + 1, EdiApiService.MAX_SAMPLE_BYTES + 4))
|
||||
{
|
||||
var response = request("POST", "/sandbox/preview", Map.of("mapId", map.getValueInteger("id"), "sampleBase64",
|
||||
Base64.getEncoder().encodeToString(new byte[size])), "user");
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(400);
|
||||
}
|
||||
for(Object body : List.of(Map.of("mapId", map.getValueInteger("id"), "sampleBase64", "!invalid!"),
|
||||
Map.of("mapId", map.getValueInteger("id")), Map.of("mapId", map.getValueInteger("id"), "sampleId", sample.getValueInteger("id"), "sampleBase64", "AAAA"),
|
||||
Map.of("mapId", map.getValueInteger("id"), "sampleId", sample.getValueInteger("id"), "guideRevision", "BAD"),
|
||||
"{\"mapId\":1.2}", "{\"mapId\":\"1\"}", "{\"mapId\":1,\"ruleOverrides\":[null]}", "[]", "null", "invalid"))
|
||||
{
|
||||
assertThat(request("POST", "/sandbox/preview", body, "user").statusCode()).isEqualTo(400);
|
||||
}
|
||||
assertThat(EdiRecords.query("edi_map_sample", new QQueryFilter())).hasSize(count);
|
||||
assertThat(request("GET", "/maps/invalid/target", null, "user").statusCode()).isEqualTo(400);
|
||||
assertThat(request("GET", "/guides/999999/tree", null, "user").statusCode()).isEqualTo(404);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** PHI is detected through hidden agreement data, and canaries never reach responses.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void phiSuppression() throws Exception
|
||||
{
|
||||
String canary = "CANARY_PRIVATE_VALUE";
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
var document = EdiRecords.query("edi_agreement_document", EdiRecords.equal("mapId", map.getValue("id"))).getFirst();
|
||||
SystemWrites.update("edi_agreement_document", new QRecord().withValue("id", document.getValue("id")).withValue("containsPhi", true));
|
||||
return (null);
|
||||
});
|
||||
var body = new java.util.LinkedHashMap<>(preview());
|
||||
body.put("ruleOverrides", List.of(Map.of("sequence", 1, "qqqPath", "poNumber", "transform", "CONSTANT", "transformArgs", "{\"value\":\"" + canary + "\"}")));
|
||||
var response = request("POST", "/sandbox/preview", body, "user");
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
assertThat(response.body()).doesNotContain(canary, "SYNTH-1", "poNumber");
|
||||
var json = JSON.readTree(response.body());
|
||||
assertThat(json.get("phiSuppressed").asBoolean()).isTrue();
|
||||
assertThat(json.get("result").isNull()).isTrue();
|
||||
assertThat(json.at("/ack/text").isNull()).isTrue();
|
||||
assertThat(json.at("/diff/againstExpected")).isEmpty();
|
||||
assertThat(json.at("/diff/againstPreviousRevision")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Rule errors are independently correlated; valid paths and transforms succeed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validateRules() throws Exception
|
||||
{
|
||||
var rules = ok("GET", "/maps/" + map.getValue("id") + "/target", null).get("rules");
|
||||
var valid = ok("POST", "/maps/" + map.getValue("id") + "/validate", Map.of("rules", rules));
|
||||
assertThat(valid.get("rules")).hasSize(5);
|
||||
valid.get("rules").forEach(rule -> assertThat(rule.get("errors")).as(rule.toString()).isEmpty());
|
||||
var invalid = List.of(
|
||||
Map.of("sequence", 1, "ediPath", "BAD99", "qqqPath", "unknown", "transform", "NO_SUCH_TRANSFORM"),
|
||||
Map.of("sequence", 2, "ediPath", "BEG03", "qqqPath", "poNumber", "transform", "LOOKUP", "transformArgs", "{}"),
|
||||
Map.of("sequence", 3, "ediPath", "PO1[*]/PO199", "qqqPath", "lines.sku", "transformArgs", "[]"),
|
||||
Map.of("sequence", 4, "ediPath", "BEG03", "qqqPath", "lines.sku", "transformArgs", "{\"index\":1}"),
|
||||
Map.of("sequence", 0, "qqqPath", "poNumber"));
|
||||
var errors = ok("POST", "/maps/" + map.getValue("id") + "/validate", Map.of("rules", invalid));
|
||||
assertThat(errors.at("/rules/0/errors")).hasSize(3);
|
||||
assertThat(errors.at("/rules/1/errors").toString()).contains("table", "key");
|
||||
assertThat(errors.at("/rules/2/errors").toString()).contains("ediPath", "transformArgs");
|
||||
assertThat(errors.at("/rules/3/errors").toString()).contains("wildcard", "transformArgs");
|
||||
assertThat(errors.at("/rules/4/errors").toString()).contains("sequence", "ediPath");
|
||||
assertThat(request("POST", "/maps/" + map.getValue("id") + "/validate", Map.of(), "user").statusCode()).isEqualTo(400);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing samples must belong to the selected map, and partner locks apply first.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sampleAndPartnerIsolation() throws Exception
|
||||
{
|
||||
QRecord another = SystemWrites.call(() -> EdiRecords.insert("edi_map", new QRecord().withValue("name", "Other map").withValue("partnerId", 1)
|
||||
.withValue("guideId", map.getValue("guideId")).withValue("tableName", "sample_order").withValue("direction", "INBOUND").withValue("transactionSet", "850")));
|
||||
assertThat(request("POST", "/sandbox/preview", Map.of("mapId", another.getValueInteger("id"), "sampleId", sample.getValueInteger("id")), "user").statusCode()).isEqualTo(404);
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_map", new QRecord().withValue("id", map.getValue("id")).withValue("partnerId", 2));
|
||||
return (null);
|
||||
});
|
||||
assertThat(request("GET", "/maps/" + map.getValue("id") + "/target", null, "user").statusCode()).isEqualTo(404);
|
||||
assertThat(request("POST", "/sandbox/preview", preview(), "user").statusCode()).isEqualTo(404);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A runtime transform failure returns a safe diagnostic instead of an exception value.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mappingFailureIsValueFree() throws Exception
|
||||
{
|
||||
EdiRuntime.current().transforms().register("FAIL", (values, args) ->
|
||||
{
|
||||
throw new IllegalArgumentException("SECRET_CANARY");
|
||||
});
|
||||
var body = new java.util.LinkedHashMap<>(preview());
|
||||
body.put("ruleOverrides", List.of(Map.of("sequence", 1, "ediPath", "BEG03", "qqqPath", "poNumber", "transform", "FAIL")));
|
||||
var response = request("POST", "/sandbox/preview", body, "user");
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
assertThat(response.body()).doesNotContain("SECRET_CANARY").contains("\"code\":\"MAP\"");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact decoded limit is accepted, including base64 expansion.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void exactUploadLimit() throws Exception
|
||||
{
|
||||
String raw = Synthetic850.generate(19, "004010", com.kingsrook.qbits.edi.x12.X12Separators.DEFAULT);
|
||||
String padded = raw + " ".repeat(EdiApiService.MAX_SAMPLE_BYTES - raw.length());
|
||||
var result = ok("POST", "/sandbox/preview", Map.of("mapId", map.getValueInteger("id"), "sampleBase64",
|
||||
Base64.getEncoder().encodeToString(padded.getBytes(StandardCharsets.US_ASCII))));
|
||||
assertThat(result.at("/parse/documents").asInt()).isEqualTo(1);
|
||||
assertThat(EdiRecords.query("edi_payload", new QQueryFilter())).anyMatch(r -> r.getValueLong("sizeBytes") == EdiApiService.MAX_SAMPLE_BYTES);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Required LOOKUP permissions are not borrowed from the configured service session.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void lookupCannotElevate() throws Exception
|
||||
{
|
||||
var body = new java.util.LinkedHashMap<>(preview());
|
||||
body.put("ruleOverrides", List.of(Map.of("sequence", 1, "ediPath", "BEG03", "qqqPath", "poNumber", "transform", "LOOKUP",
|
||||
"transformArgs", "{\"table\":\"sample_order\",\"key\":\"poNumber\"}")));
|
||||
var response = request("POST", "/sandbox/preview", body, "notarget");
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(403);
|
||||
assertThat(request("POST", "/sandbox/preview", preview(), "norules").statusCode()).isEqualTo(403);
|
||||
assertThat(request("POST", "/maps/" + map.getValue("id") + "/validate", Map.of("rules", List.of()), "noguide").statusCode()).isEqualTo(403);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A later active guide changes CURRENT findings while ORIGINAL keeps the pinned guide.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void guideRevisionSelection() throws Exception
|
||||
{
|
||||
var repository = new com.kingsrook.qbits.edi.guide.GuideRepository();
|
||||
var guide = repository.load(map.getValueInteger("guideId"));
|
||||
var segments = new ArrayList<>(guide.segments());
|
||||
segments.add(segments.size() - 1, new com.kingsrook.qbits.edi.guide.GuideSegment("XYZ", null, null, 1, "REQUIRED", 1, 1, List.of(), List.of(), null));
|
||||
var changed = new com.kingsrook.qbits.edi.guide.Guide(guide.name(), guide.standard(), guide.version(), guide.transactionSet(), guide.functionalIdCode(), segments, guide.rules());
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
Integer id = repository.save(changed, "synthetic-http-revision", 2);
|
||||
SystemWrites.update("edi_guide", new QRecord().withValue("id", id).withValue("partnerId", 1));
|
||||
return (null);
|
||||
});
|
||||
var body = new java.util.LinkedHashMap<>(preview());
|
||||
body.put("guideRevision", "ORIGINAL");
|
||||
var original = ok("POST", "/sandbox/preview", body);
|
||||
body.put("guideRevision", "CURRENT");
|
||||
var current = ok("POST", "/sandbox/preview", body);
|
||||
assertThat(current.get("findings").size()).isGreaterThan(original.get("findings").size());
|
||||
assertThat(current.get("findings").toString()).contains("XYZ");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Previous-revision and expected diffs identify the changed document and values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void revisionAndExpectedDiffs() throws Exception
|
||||
{
|
||||
QRecord revised = SystemWrites.call(() ->
|
||||
{
|
||||
QRecord next = new QRecord().withValue("revision", 2).withValue("partnerId", 1).withValue("guideId", map.getValue("guideId"));
|
||||
for(String field : List.of("name", "direction", "standard", "version", "transactionSet", "tableName", "status"))
|
||||
{
|
||||
next.withValue(field, map.getValue(field));
|
||||
}
|
||||
return (EdiRecords.insert("edi_map", next));
|
||||
});
|
||||
var nextSample = saveSample(Synthetic850.generate(17, "004010", com.kingsrook.qbits.edi.x12.X12Separators.DEFAULT), revised.getValueInteger("id"));
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_map_sample", new QRecord().withValue("id", nextSample.getValue("id")).withValue("expectedResultJson", "{\"values\":{\"poNumber\":\"EXPECTED\"},\"associations\":{}}"));
|
||||
return (null);
|
||||
});
|
||||
var result = ok("POST", "/sandbox/preview", Map.of("mapId", revised.getValueInteger("id"), "sampleId", nextSample.getValueInteger("id"),
|
||||
"ruleOverrides", List.of(Map.of("sequence", 1, "qqqPath", "poNumber", "transform", "CONSTANT", "transformArgs", "{\"value\":\"EDITED\"}"))));
|
||||
assertThat(result.at("/diff/againstPreviousRevision")).hasSize(1);
|
||||
assertThat(result.at("/diff/againstExpected")).hasSize(1);
|
||||
assertThat(result.at("/diff/againstPreviousRevision/0/after/values/poNumber").asText()).isEqualTo("EDITED");
|
||||
assertThat(result.at("/diff/againstExpected/0/before/values/poNumber").asText()).isEqualTo("EXPECTED");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Large code lists are deterministically capped in the guide-tree projection.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void codeListLimit() throws Exception
|
||||
{
|
||||
var definitions = EdiRecords.query("edi_guide_segment", EdiRecords.equal("guideId", map.getValue("guideId")));
|
||||
Integer codeListId = null;
|
||||
for(var definition : definitions)
|
||||
{
|
||||
for(var element : EdiRecords.query("edi_guide_element", EdiRecords.equal("guideSegmentId", definition.getValue("id"))))
|
||||
{
|
||||
if(element.getValueInteger("codeListId") != null)
|
||||
{
|
||||
codeListId = element.getValueInteger("codeListId");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(codeListId != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
final Integer listId = codeListId;
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
for(int i = 0; i < 205; i++)
|
||||
{
|
||||
EdiRecords.insert("edi_code_list_value", new QRecord().withValue("codeListId", listId).withValue("code", "SYNTH" + i).withValue("isActive", true));
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
var tree = ok("GET", "/guides/" + map.getValue("guideId") + "/tree", null);
|
||||
assertThat(tree.findValues("codes")).allSatisfy(codes -> assertThat(codes.size()).isLessThanOrEqualTo(200));
|
||||
assertThat(tree.findValues("codes")).anyMatch(codes -> codes.size() == 200);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transaction and interchange envelope errors are represented in findings and acknowledgements.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void envelopeFindings() throws Exception
|
||||
{
|
||||
String raw = Synthetic850.generate(20, "004010", com.kingsrook.qbits.edi.x12.X12Separators.DEFAULT);
|
||||
var invalid = saveSample(raw.replace("SE*9*", "SE*99*").replace("IEA*1*", "IEA*2*"), map.getValueInteger("id"));
|
||||
var response = ok("POST", "/sandbox/preview", Map.of("mapId", map.getValueInteger("id"), "sampleId", invalid.getValueInteger("id")));
|
||||
assertThat(response.get("findings")).hasSizeGreaterThanOrEqualTo(2);
|
||||
assertThat(response.get("findings").toString()).contains("\"snipLevel\":1");
|
||||
assertThat(response.at("/ack/type").asText()).isEqualTo("MIXED");
|
||||
assertThat(response.at("/ack/text").asText()).contains("TA1*", "ISA*", "AK9*");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A caller cannot inspect another partner's guide via a visible map.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hiddenGuide() throws Exception
|
||||
{
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_guide", new QRecord().withValue("id", map.getValue("guideId")).withValue("partnerId", 2));
|
||||
return (null);
|
||||
});
|
||||
assertThat(request("GET", "/guides/" + map.getValue("guideId") + "/tree", null, "user").statusCode()).isEqualTo(404);
|
||||
assertThat(request("POST", "/sandbox/preview", preview(), "user").statusCode()).isEqualTo(404);
|
||||
assertThat(request("POST", "/maps/" + map.getValue("id") + "/validate", Map.of("rules", List.of()), "user").statusCode()).isEqualTo(404);
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,10 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.edi.metadata.EdiTables;
|
||||
import com.kingsrook.qbits.edi.security.EdiClassifications;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
|
||||
@@ -52,6 +53,34 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
*******************************************************************************/
|
||||
class EdiPresentationTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Core classification resolution preserves PHI/PII and the confidential default.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void coreClassifications() throws Exception
|
||||
{
|
||||
var instance = H2Support.create();
|
||||
for(var table : EdiTables.tables(new EdiQBitConfig().withBackendName("edi")))
|
||||
{
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofTable(table)).isEqualTo(DataClassification.CONFIDENTIAL);
|
||||
}
|
||||
for(String[] pair : List.of(new String[] {"edi_transaction", "phiBusinessKey"}, new String[] {"edi_transaction_error", "valueExcerpt"},
|
||||
new String[] {"edi_map_sample", "expectedResultJson"}, new String[] {"edi_inbox", "fileName"}, new String[] {"edi_payload", "ciphertext"}))
|
||||
{
|
||||
var table = instance.getTable(pair[0]);
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(table, pair[1])).isEqualTo(DataClassification.PHI);
|
||||
assertThat(AiFieldMetaData.of(table.getField(pair[1])).getExposure()).isEqualTo(com.kingsrook.qqq.backend.core.actions.ai.AiExposure.NONE);
|
||||
}
|
||||
for(String field : List.of("contactName", "contactEmail", "contactPhone"))
|
||||
{
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("edi_partner"), field)).isEqualTo(DataClassification.PII);
|
||||
}
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("edi_partner"), "code")).isEqualTo(DataClassification.CONFIDENTIAL);
|
||||
HealthcareSupport.tables();
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("sample_claim"), "patientName")).isEqualTo(DataClassification.PHI);
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("sample_claim_line"), "parentId")).isEqualTo(DataClassification.CONFIDENTIAL);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All registered tables have readable labels and linked internal references.
|
||||
*******************************************************************************/
|
||||
@@ -79,8 +108,8 @@ class EdiPresentationTest
|
||||
}
|
||||
for(String name : table.getRecordLabelFields() == null ? List.<String>of() : table.getRecordLabelFields())
|
||||
{
|
||||
var classification = (EdiClassifications) table.getField(name).getSupplementalMetaData().get("edi.classification");
|
||||
assertThat(classification.getClassification()).as(table.getName() + "." + name).isNotEqualTo("PHI");
|
||||
var classification = AiFieldMetaData.of(table.getField(name));
|
||||
assertThat(classification.getClassification()).as(table.getName() + "." + name).isNotEqualTo(DataClassification.PHI);
|
||||
}
|
||||
}
|
||||
assertThat(instance.getTable("edi_exception").getField("assignedToUserId").getLabel()).isEqualTo("Assigned To");
|
||||
|
||||
@@ -37,6 +37,8 @@ import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
import com.kingsrook.qbits.edi.x12.X12Separators;
|
||||
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
|
||||
import com.kingsrook.qbits.edi.x12.X12Writer;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
@@ -93,7 +95,7 @@ public final class HealthcareSupport
|
||||
{
|
||||
String[] parts = field.split(":");
|
||||
table.withField(new QFieldMetaData(parts[0], parts.length > 1 ? QFieldType.valueOf(parts[1]) : QFieldType.STRING).withBackendName(QInstanceEnricher.inferBackendName(parts[0]))
|
||||
.withSupplementalMetaData(new com.kingsrook.qbits.edi.security.EdiClassifications(parts[0].equals("parentId") ? "CONFIDENTIAL" : "PHI")));
|
||||
.withSupplementalMetaData(new AiFieldMetaData().withClassification(parts[0].equals("parentId") ? DataClassification.CONFIDENTIAL : DataClassification.PHI)));
|
||||
}
|
||||
instance.addTable(table);
|
||||
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) instance.getBackend(backend)); var statement = connection.createStatement())
|
||||
|
||||
@@ -67,6 +67,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Synthetic H2 vertical slice, including permission, retry and UI boundaries.
|
||||
@@ -337,6 +338,15 @@ class InboundTest
|
||||
*******************************************************************************/
|
||||
public static class CapturedAlerts implements com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface
|
||||
{
|
||||
static final List<com.kingsrook.qqq.backend.core.alerts.NotificationSignal> notifications = new ArrayList<>();
|
||||
/*******************************************************************************
|
||||
** Capture actual core notification dispatch.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(com.kingsrook.qqq.backend.core.alerts.NotificationSignal signal)
|
||||
{
|
||||
notifications.add(signal);
|
||||
}
|
||||
static final List<String> raised = new ArrayList<>();
|
||||
static final List<String> resolved = new ArrayList<>();
|
||||
/*******************************************************************************
|
||||
@@ -409,6 +419,9 @@ class InboundTest
|
||||
return (null);
|
||||
});
|
||||
QRecord exception = rows("edi_exception").getFirst();
|
||||
CapturedAlerts.notifications.clear();
|
||||
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance())
|
||||
.withSink(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(CapturedAlerts.class));
|
||||
for(String action : List.of("assign", "ignore", "resolve"))
|
||||
{
|
||||
input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName("edi_exception_" + action).withRecords(List.of(exception))
|
||||
@@ -417,6 +430,85 @@ class InboundTest
|
||||
}
|
||||
assertThat(rows("edi_exception").getFirst().getValueString("status")).isEqualTo("RESOLVED");
|
||||
assertThat(rows("edi_exception").getFirst().getValueString("assignedToUserId")).isEqualTo("synthetic-user");
|
||||
assertThat(CapturedAlerts.notifications).singleElement().satisfies(signal ->
|
||||
{
|
||||
assertThat(signal.getAudience().getValues()).containsExactly("synthetic-user");
|
||||
assertThat(signal.getTitle()).isEqualTo("EDI exception assigned");
|
||||
assertThat(signal.getBody()).isEqualTo("An EDI exception has been assigned to you.");
|
||||
assertThat(signal.getSourceTableName()).isEqualTo("edi_exception");
|
||||
assertThat(signal.getSourceRecordId()).isEqualTo(exception.getValueString("id"));
|
||||
});
|
||||
input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName("edi_exception_assign")
|
||||
.withRecords(List.of(exception)).withValues(Map.of("assignedToUserId", " "));
|
||||
output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
|
||||
execute.runOnePage(input, output);
|
||||
assertThat(execute.getProcessSummary(output, true).toString()).contains("record could not be processed");
|
||||
assertThat(CapturedAlerts.notifications).hasSize(1);
|
||||
assertThat(rows("edi_exception").getFirst().getValueString("status")).isEqualTo("RESOLVED");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Newly opened partner failures notify current assignees once, after commit only.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"REJECTED_ACK", "DEAD_LETTER"})
|
||||
void notifyPartnerAssignees(String type) throws Exception
|
||||
{
|
||||
setup("004010");
|
||||
CapturedAlerts.notifications.clear();
|
||||
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance())
|
||||
.withSink(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(CapturedAlerts.class));
|
||||
Integer partnerId = partner.getValueInteger("id");
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
for(String status : List.of("OPEN", "IN_PROGRESS", "RESOLVED", "IGNORED"))
|
||||
{
|
||||
SystemWrites.insert("edi_exception", new QRecord().withValue("partnerId", partnerId).withValue("type", "APPLY")
|
||||
.withValue("status", status).withValue("assignedToUserId", List.of("OPEN", "IN_PROGRESS").contains(status) ? "current-user" : "closed-user"));
|
||||
}
|
||||
SystemWrites.insert("edi_exception", new QRecord().withValue("partnerId", partnerId + 1).withValue("type", "APPLY")
|
||||
.withValue("status", "OPEN").withValue("assignedToUserId", "other-partner-user"));
|
||||
return (null);
|
||||
});
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(100, null, null, partnerId, type, "Synthetic private text");
|
||||
assertThat(CapturedAlerts.notifications).isEmpty();
|
||||
return (null);
|
||||
}));
|
||||
assertThat(CapturedAlerts.notifications).singleElement().satisfies(signal ->
|
||||
{
|
||||
assertThat(signal.getAudience().getValues()).containsExactly("current-user");
|
||||
assertThat(signal.getTitle()).isEqualTo("EDI exception opened");
|
||||
assertThat(signal.getBody()).isEqualTo("An acknowledgement rejection or dead-letter exception needs attention.");
|
||||
assertThat(signal.getSourceTableName()).isEqualTo("edi_exception");
|
||||
assertThat(signal.getSourceRecordId()).isNotBlank();
|
||||
});
|
||||
CapturedAlerts.notifications.clear();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(100, null, null, partnerId, type, "Repeated failure");
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(101, null, null, null, type, "No partner");
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(102, null, null, partnerId, "VALIDATION", "Other type");
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(103, null, null, partnerId + 2, type, "No assignees");
|
||||
return (null);
|
||||
});
|
||||
assertThat(CapturedAlerts.notifications).isEmpty();
|
||||
assertThatThrownBy(() -> SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(104, null, null, partnerId, type, "Rolled back");
|
||||
throw new IllegalStateException("Synthetic rollback");
|
||||
}))).isInstanceOf(Exception.class);
|
||||
assertThat(CapturedAlerts.notifications).isEmpty();
|
||||
assertThat(EdiRecords.query("edi_exception", EdiRecords.equal("inboxId", 104))).isEmpty();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
QRecord row = EdiRecords.query("edi_exception", EdiRecords.equal("inboxId", 100)).getFirst();
|
||||
SystemWrites.update("edi_exception", new QRecord().withValue("id", row.getValue("id")).withValue("status", "RESOLVED"));
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(100, null, null, partnerId, type, "Reopened");
|
||||
return (null);
|
||||
});
|
||||
assertThat(CapturedAlerts.notifications).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -329,4 +329,20 @@ class OutboundTest
|
||||
{
|
||||
return (SystemWrites.call(() -> EdiRecords.query(table, new QQueryFilter())));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeat-seconds worker schedules carry no cron time zone (core instance
|
||||
** validation refuses one on a non-cron schedule); cron schedules use UTC.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void repeatSchedulesHaveNoCronTimeZone()
|
||||
{
|
||||
com.kingsrook.qbits.edi.EdiQBitConfig config = new com.kingsrook.qbits.edi.EdiQBitConfig().withSchedulerName("scheduler");
|
||||
var repeat = com.kingsrook.qbits.edi.processes.OutboundProcesses.process("edi_test_repeat", config, 15, null).getSchedule();
|
||||
assertThat(repeat.getRepeatSeconds()).isEqualTo(15);
|
||||
assertThat(repeat.getCronTimeZoneId()).isNull();
|
||||
var cron = com.kingsrook.qbits.edi.processes.OutboundProcesses.process("edi_test_cron", config, null, "0 0 * * * ?").getSchedule();
|
||||
assertThat(cron.getCronTimeZoneId()).isEqualTo("UTC");
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,6 @@ import com.kingsrook.qbits.edi.flow.InboxWorker;
|
||||
import com.kingsrook.qbits.edi.flow.SendWorker;
|
||||
import com.kingsrook.qbits.edi.metadata.EdiTables;
|
||||
import com.kingsrook.qbits.edi.processes.EdiProcesses;
|
||||
import com.kingsrook.qbits.edi.security.EdiClassifications;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadCrypto;
|
||||
import com.kingsrook.qbits.edi.storage.PreviewDownloadBackend;
|
||||
@@ -55,6 +54,8 @@ import com.kingsrook.qbits.edi.transport.gateway.EdiFileGatewayConsumer;
|
||||
import com.kingsrook.qbits.edi.transport.gateway.standin.FileGatewayOutbox;
|
||||
import com.kingsrook.qbits.edi.transport.gateway.standin.InboundFile;
|
||||
import com.kingsrook.qbits.edi.transport.gateway.standin.OutboxFileRef;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -78,7 +79,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** End-to-end PHI egress scanner. Only explicit PHI columns may retain sentinels.
|
||||
** RC.5 has no secured-data tier resolver; FULL exports and positional egress run here.
|
||||
** RC.6 has no secured-data tier resolver; FULL exports and positional egress run here.
|
||||
*******************************************************************************/
|
||||
class PhiCanaryTest
|
||||
{
|
||||
@@ -120,8 +121,8 @@ class PhiCanaryTest
|
||||
{
|
||||
for(var field : QContext.getQInstance().getTable(table).getFields().values())
|
||||
{
|
||||
var classification = field.getSupplementalMetaData("edi.classification");
|
||||
if(classification instanceof EdiClassifications classified && classified.getClassification().equals("PHI"))
|
||||
var classification = AiFieldMetaData.of(field);
|
||||
if(classification != null && classification.getClassification() == DataClassification.PHI)
|
||||
{
|
||||
values.remove(field.getName());
|
||||
}
|
||||
@@ -158,6 +159,7 @@ class PhiCanaryTest
|
||||
demo.seed();
|
||||
var instance = demo.defineQInstance();
|
||||
CapturedAlerts.outputs.clear();
|
||||
CapturedAlerts.notifications.clear();
|
||||
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(CapturedAlerts.class));
|
||||
// A test-only widened diagnosis field allows the long marker through mapping;
|
||||
// its invalid code shape still exercises SNIP 5, and separate documents force SNIP 2.
|
||||
@@ -250,6 +252,13 @@ class PhiCanaryTest
|
||||
}
|
||||
for(QRecord exception : rows("edi_exception"))
|
||||
{
|
||||
process("edi_exception_assign", exception);
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(exception.getValueInteger("inboxId"), exception.getValueInteger("interchangeId"),
|
||||
exception.getValueInteger("transactionId"), exception.getValueInteger("partnerId"), "DEAD_LETTER", "Synthetic PHI failure");
|
||||
return (null);
|
||||
}));
|
||||
process("edi_exception_resolve", exception);
|
||||
}
|
||||
while(new SendWorker().runOnce() > 0)
|
||||
@@ -266,6 +275,8 @@ class PhiCanaryTest
|
||||
SystemWrites.call(() -> { SystemWrites.update("edi_functional_group", new QRecord().withValue("id", group.getValue("id")).withValue("ackStatus", "PENDING").withValue("ackDueAt", Instant.now().minusSeconds(60))); return (null); });
|
||||
new AckMonitor().runOnce();
|
||||
assertThat(CapturedAlerts.outputs).isNotEmpty();
|
||||
assertThat(CapturedAlerts.notifications).anyMatch(json -> json.contains("EDI exception assigned"));
|
||||
assertThat(CapturedAlerts.notifications).anyMatch(json -> json.contains("EDI exception opened"));
|
||||
CapturedAlerts.outputs.forEach(output -> scan(output, "alerts"));
|
||||
for(var table : instance.getTables().values())
|
||||
{
|
||||
@@ -340,7 +351,7 @@ class PhiCanaryTest
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 HIDDEN field locks are real enforcement, not test-side JSON redaction.
|
||||
** RC.6 HIDDEN field locks are real enforcement, not test-side JSON redaction.
|
||||
*******************************************************************************/
|
||||
private void hiddenExportsAndWidgets() throws Exception
|
||||
{
|
||||
@@ -358,7 +369,7 @@ class PhiCanaryTest
|
||||
viewer.withPermissions("edi." + table.getName().substring(4) + ".read");
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
if(field.getSupplementalMetaData("edi.classification") instanceof EdiClassifications classified && classified.getClassification().equals("PHI"))
|
||||
if(AiFieldMetaData.of(field) != null && AiFieldMetaData.of(field).getClassification() == DataClassification.PHI)
|
||||
{
|
||||
field.withFieldSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock().withSecurityKeyType("synthetic_phi_access"));
|
||||
}
|
||||
@@ -401,6 +412,10 @@ class PhiCanaryTest
|
||||
private RunBackendStepOutput process(String name, QRecord record) throws Exception
|
||||
{
|
||||
var input = new RunBackendStepInput().withProcessName(name).withRecords(List.of(record));
|
||||
if(name.equals("edi_exception_assign"))
|
||||
{
|
||||
input.withValues(Map.of("assignedToUserId", "synthetic-canary-user"));
|
||||
}
|
||||
var review = new EdiProcesses.Review();
|
||||
var preview = new RunBackendStepOutput();
|
||||
review.runOnePage(input, preview);
|
||||
@@ -428,6 +443,19 @@ class PhiCanaryTest
|
||||
public static class CapturedAlerts implements com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface
|
||||
{
|
||||
static final List<String> outputs = new ArrayList<>();
|
||||
static final List<String> notifications = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Scan the complete notification payload for PHI, including attributes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(com.kingsrook.qqq.backend.core.alerts.NotificationSignal signal)
|
||||
{
|
||||
String json = JsonUtils.toJson(signal);
|
||||
notifications.add(json);
|
||||
outputs.add(json);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain every serialized signal for the substring scanner.
|
||||
|
||||
Reference in new issue
Block a user