DMN Validator Implementation¶
Architecture overview¶
Validation is split across three layers:
Browser (React) Backend (Node.js / Express)
───────────────────── ──────────────────────────────────────────────
DmnValidator.tsx packages/backend/src/
│ POST /v1/dmns/validate ├── routes/dmn.routes.ts (route handler)
│ { content: string } ───► └── services/dmn-validation.service.ts
│ (five-layer validation engine)
◄──────────────────────────────
{ success, data: ValidationResult }
The frontend component is a pure React display layer. All business logic lives in the backend service.
Frontend — DmnValidator.tsx¶
Location: packages/frontend/src/components/DmnValidator.tsx
State model¶
interface DmnEntry {
id: string; // generated: Date.now()-random
name: string;
size: number;
content: string; // raw XML string read by FileReader
isValidating: boolean;
result: ValidationResult | null;
error: string | null;
}
useState<DmnEntry[]>([]) holds all loaded files. Each entry is updated immutably by id when its validation completes.
Key behaviours¶
Persistence across navigation. The component is rendered unconditionally in App.tsx and hidden with the Tailwind hidden class when not active:
<div className={`flex-1 overflow-hidden flex flex-col ${
viewMode === ViewMode.VALIDATE ? '' : 'hidden'
}`}>
<DmnValidator apiBaseUrl={API_BASE_URL} />
</div>
This keeps React from unmounting the component — and therefore from discarding state — when the user switches views.
Multi-file drop. The addFiles(files) helper iterates FileList | File[], rejects non-.dmn/.xml files with a toast, and for each accepted file spawns a FileReader that appends a new DmnEntry to state on onload.
Concurrent validation. validateAll() calls validateEntry(id) for every entry that is not already validating. Each call is an independent fetch — they run in parallel.
Non-blocking errors. A fetch or parse failure sets entry.error and clears entry.isValidating. It never throws or blocks other entries.
Component tree¶
DmnValidator
├── Header (title, "Validate all", "Clear all")
├── Drop zone (always mounted, compact when entries exist)
├── Drop error toast (auto-dismisses after 4 s)
└── Entry cards (horizontal scrollable row)
└── EntryCard (one per DmnEntry)
├── Card header (filename, size, Validate, ×)
└── Card body
├── "Press Validate to run checks" placeholder
├── Validation spinner
├── Error message
└── Result
├── Summary badge (valid/invalid + E/W/I counts)
└── LayerSection × 5
└── IssueRow × n
Backend — route handler¶
Location: packages/backend/src/routes/dmn.routes.ts
router.post('/validate', async (req: Request, res: Response) => {
const { content } = req.body as { content?: string };
if (!content || typeof content !== 'string') {
return res.status(400).json({
success: false,
error: { code: 'INVALID_REQUEST', message: '...' },
});
}
const result = await dmnValidationService.validateDmnContent(content);
res.json({ success: true, data: result, timestamp: new Date().toISOString() });
});
The endpoint is unauthenticated. Body size is limited to 10 MB by the express.json() middleware in index.ts. The route is registered under /v1/dmns, so the full path is POST /v1/dmns/validate.
Backend — validation service¶
Location: packages/backend/src/services/dmn-validation.service.ts
Dependency¶
libxmljs2 is the only added dependency. It provides:
- XML well-formedness parsing (
parseXml) - A full DOM-like API with XPath support (
find(),get(),.attr(),.namespace()) - Namespace-aware attribute lookup
No XSD validation is used. The library's XSD compiler rejects complex forward-referencing schemas; all structural checks are performed programmatically instead.
Entry point¶
export async function validateDmnContent(xmlContent: string): Promise<DmnValidationResult> {
const { layer: baseLayer, doc } = await validateBaseLayer(xmlContent);
// If XML could not be parsed, abort — layers 2–5 require a valid DOM
const parseFailure = baseLayer.issues.find(i => i.code === 'BASE-PARSE');
if (parseFailure) return buildResult(parseFailure.message, baseLayer);
if (!doc) return buildResult(null, baseLayer);
return buildResult(
null,
baseLayer,
validateBusinessLayer(doc),
validateExecutionLayer(doc, xmlContent),
validateInteractionLayer(doc),
validateContentLayer(doc),
);
}
Namespace constants¶
const DMN_NS = 'https://www.omg.org/spec/DMN/20191111/MODEL/';
const CPRMV_NS = 'https://cprmv.open-regels.nl/0.3.0/';
const NS = { d: DMN_NS };
All XPath queries use the d: prefix mapped to DMN_NS. The namespace-agnostic fallback //*[local-name()="decision"] is used in Layer 1 to handle any DMN version.
Helper functions¶
find(node, xpath, ns?) // → XmlElement[] — returns [] on error
get(node, xpath, ns?) // → XmlElement | null
cprmvAttr(el, name) // → string | null — tries namespace-aware then prefix scan
elLoc(el) // → '<tagName id="..." />' for issue location strings
iss(severity, code, msg, location?, line?, column?) // → ValidationIssue
Adding a new validation check¶
- Identify the appropriate layer function (
validateBusinessLayer,validateExecutionLayer, etc.). - Add an XPath query using
find()orget()to locate the relevant elements. - Push a new issue using
iss('error'|'warning'|'info', 'LAYER-NNN', 'message', elLoc(el)). - Document the new code in the DMN Validation Reference.
Code numbering convention: BASE-NNN, BIZ-NNN, EXEC-NNN, INT-NNN, CON-NNN. Use the next available number in the relevant range.
Environment variables¶
| Variable | Component | Default |
|---|---|---|
VITE_API_BASE_URL |
Linked Data Explorer frontend | http://localhost:3001 |
REACT_APP_BACKEND_URL |
CPSV Editor frontend | http://localhost:3001 |