Skip to content

DMN Implementation


Component structure

src/
├── components/tabs/
│   └── DMNTab.jsx                    # Main UI (900+ lines)
│       â€ĸ File upload + card display
│       â€ĸ Syntactic validation panel
│       â€ĸ API configuration
│       â€ĸ Deployment controls
│       â€ĸ Single evaluate (Postman-style)
│       â€ĸ Intermediate decision tests
│       â€ĸ Test cases upload + runner
│       â€ĸ Import preservation notice
│
├── utils/
│   ├── dmnHelpers.js                 # DMN TTL generation utilities (370 lines)
│   └── parseTTL.enhanced.js          # DMN block capture on import (523 lines)
│
└── config/
    └── vocabularies_config.js        # DMN entity type detection

DMN state shape

dmnData: {
  // File
  fileName: string,
  content: string,              // Raw DMN XML

  // Deployment
  decisionKey: string,          // Primary key extracted from DMN
  deployed: boolean,
  deploymentId: string | null,
  deployedAt: string | null,

  // API
  apiEndpoint: string,

  // Testing
  lastTestResult: object | null,
  lastTestTimestamp: string | null,
  testBody: string | null,

  // Import preservation (v1.5.1+)
  importedDmnBlocks: string | null,  // Raw Turtle lines preserved verbatim
  isImported: boolean,
}

Syntactic validation

Overview

When a DMN file is uploaded or an example is loaded, DMNTab.jsx calls the shared backend validation endpoint and stores the result in local component state.

// State added in v1.9.3
const [validationResult, setValidationResult] = useState(null);
const [isValidating, setIsValidating]         = useState(false);
const [validationExpanded, setValidationExpanded] = useState(false);

Backend call

const runBackendValidation = async (content) => {
  setIsValidating(true);
  try {
    const response = await fetch(
      `${process.env.REACT_APP_BACKEND_URL}/v1/dmns/validate`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content }),
      }
    );
    const data = await response.json();
    if (data.success) {
      setValidationResult(data.data);
      // Auto-expand the panel when errors are present
      setValidationExpanded(data.data.summary.errors > 0);
    }
} catch (err) {
    // Backend unreachable — surface a neutral "unavailable" state rather than
    // failing silently. DMN workflow remains fully functional; only the
    // syntactic pre-check is skipped.
    setValidationResult({
      valid: false,
      unavailable: true,
      parseError:
        'Syntax validation result not available — the validation backend ' +
        `could not be reached (${err.message}). DMN deployment and testing ` +
        'still work; only syntax pre-checks are skipped.',
      layers: { base: { issues: [] }, business: { issues: [] }, execution: { issues: [] }, interaction: { issues: [] }, content: { issues: [] } },
      summary: { errors: 0, warnings: 0, infos: 0 },
    });
  } finally {
    setIsValidating(false);
  }
};

The call is made:

  • After handleFileUpload — reader.onload completes
  • After loadExampleDMN — on successful fetch
  • Cleared in handleClearFile — setValidationResult(null); setIsValidating(false);

Backend unreachability handling

A network failure or unavailable backend does not block the DMN workflow — deployment, single evaluate, intermediate tests, and the test-cases runner all continue to function. From v1.9.5 onward the validation panel renders a distinct amber "Syntax validation result not available" state to make the situation visible, kept visually separate from the red "Syntax issues found" state that indicates an actual DMN problem. The amber state is signalled by validationResult.unavailable === true; the per-layer issue arrays and the error/warning/info counts are empty in that case.

Response shape

interface ValidationResult {
  valid: boolean;
  unavailable?: boolean;  // v1.9.5+ — true when the backend was unreachable; layers and summary are empty
  parseError: string | null;
  layers: {
    base:        { label: string; issues: Issue[] };
    business:    { label: string; issues: Issue[] };
    execution:   { label: string; issues: Issue[] };
    interaction: { label: string; issues: Issue[] };
    content:     { label: string; issues: Issue[] };
  };
  summary: { errors: number; warnings: number; infos: number };
}

interface Issue {
  severity: 'error' | 'warning' | 'info';
  code: string;      // e.g. 'EXEC-001', 'INT-005'
  message: string;
  location?: string; // element description e.g. '<decision id="d1">'
  line?: number;
  column?: number;
}

UI rendering

The validation panel is rendered inside the file card, between the file info row and the Deploy button. It is hidden when validationResult is null or isValidating is true (a spinner is shown instead).

The collapsible layer rows follow the same pattern as the Linked Data Explorer's DmnValidator component — each layer shows an icon and badge counts at a glance and expands on click to show individual issue rows.


Import preservation

Detection

vocabularies_config.js detects DMN entities before regular entities to avoid misclassification:

export const detectEntityType = (line) => {
  // DMN detection FIRST
  if (line.includes('a cprmv:DecisionModel')) return 'dmnModel';
  if (line.includes('a cpsv:Input'))          return 'dmnInput';
  if (line.includes('a cprmv:DecisionRule'))  return 'dmnRule';

  // Regular entity detection below...
};

Capture

parseTTL.enhanced.js captures DMN lines verbatim when detected:

let inDmnSection = false;
let dmnLines = [];

if (['dmnModel', 'dmnInput', 'dmnRule'].includes(detectedType)) {
  if (!inDmnSection) {
    inDmnSection = true;
    parsed.hasDmnData = true;
  }
  dmnLines.push(rawLine);  // exact line, no transformation
  continue;
}

if (parsed.hasDmnData && dmnLines.length > 0) {
  parsed.importedDmnBlocks = dmnLines.join('\n');
}

Export

ttlGenerator.js appends preserved blocks at the end of the generated output — after all form-based sections. Before appending, generateDmnSection runs normalizeImportedDmnBlocks (v1.10.2): a CPSV-AP conformance pass over the preserved cpsv:Rule (Decision Rule) blocks that injects missing dct:title/dct:description and repoints a cpsv:implements that targets the cpsv:PublicService to the eli:LegalResource (or drops it when no legal resource exists). The edits are additive/repointing only — no preserved triples are removed — and idempotent, so re-importing an already-conformant export is a no-op.


Primary decision key extraction

The extractPrimaryDecisionKey() helper skips constant parameters automatically:

// Filters decisions with p_* prefix (constants)
// Prefers a *root* decision — one no other decision requires via
//   informationRequirement/requiredDecision (v1.10.3)
// Document order breaks ties between multiple independent roots
// Falls back to the first decision if all are constants

As of v1.9.6 this helper lives in src/utils/dmnHelpers.js and is exported, so both DMNTab.jsx and the DSO import hook share one implementation instead of duplicating the parser.

Root-decision preference (v1.10.3). The helper previously returned the last non-constant decision, which broke models whose intended output decision is authored earlier or in a non-obvious position. It now prefers a decision that no other decision requires (a DRD root). When several independent roots exist (e.g. a combined Recht-Ên-Hoogte model) document order still decides, and a console warning points to the Decision Key dropdown. That dropdown renders on the DMN File card whenever a file has more than one testable decision, listing each as Name (id); selecting one updates the decision key and the evaluation URL everywhere.

Console log on extraction:

[DMN] Extracted primary decision key: "zorgtoeslag_resultaat" (skipped 8 p_* constant(s))



Request body generation

When a DMN file is loaded, generateRequestBodyFromDMN walks the top-level <inputData> elements and builds a starter request body. For each input it picks a starter value using three sources, in priority order:

  1. <inputValues> constraint (v1.9.5+). If the inputData name matches the <inputExpression> text of a decisionTable input column that carries an <inputValues> FEEL allowed-values list, the first allowed value is used. Quoted strings are unwrapped; booleans and numbers are coerced from their FEEL literal forms.
  2. typeRef from the inputData <variable> child. When no <inputValues> constraint applies, the type drives a switch: boolean → false, integer/long → 0, number/double/decimal → 0, date → today's date in YYYY-MM-DD, string → empty.
  3. Name-based heuristics. string-typed inputs whose name contains datum/date/dag default to today's date; geboorte defaults to a random adult birth date; aantal/bedrag/inkomen default to 0. Heuristics run only when steps 1 and 2 leave the value empty.

The resulting body is editable in the DMN tab's request-body panel before each evaluate call. Authors who want a richer starter body without writing custom code can simply add <inputValues> constraints to the relevant decisionTable input columns — this also documents intent in the DMN itself, which downstream tooling (validators, decision-table editors) can consume.


Test-case verification

evaluateTestCaseExpectation (v1.10.3) compares each uploaded test case's expected outputs against the engine's actual outputs and returns a verdict, so a case only passes when it is functionally correct — not merely because the HTTP call returned 200. It reads the expectation from the readable key=value, reden="â€Ļ" string, a structured expected object, or the special empty-result case, and yields one of:

Verdict Condition
PASS expected outputs match actual
FAIL mismatch — an Expected-vs-Actual table is rendered per output
ERROR the evaluate call itself failed
OK-unchecked no expectation could be parsed (amber; never a silent pass)

Summary and header counts are verdict-based. Run All Test Cases routes each case to its own decision — a case's optional decision field is the evaluation key, falling back to the selected Decision Key (v1.10.4).

Empty-result expectations (v1.10.6). The empty-result branch originally matched only the descriptive strings empty result / no matching rule, so a literal [] (or {}) expectation fell through to the key=value parser, found no pairs, and was judged OK-unchecked even though an empty engine response was exactly correct. The check now also matches a literal empty array or object, so [] is treated as expect an empty result set (e.g. Thuisbatterij jaarGebondenBudget years 2025/2028 outside the modelled range now pass). A non-empty response against an [] expectation still fails.


The useDsoImport hook (src/hooks/useDsoImport.js, v1.9.6) lets the Linked Data Explorer hand a toepasbare-regel DMN straight into the editor via a deep-link:

/?dsoImport=dmn&dmnId=<id>&env=<pre|prod>&activityName=<â€Ļ>
  &authority=<â€Ļ>&activityUrn=<â€Ļ>&fsRef=<â€Ļ>

On mount (guarded by a consumedRef against StrictMode double-invoke) the hook:

  1. fetches the standalone DMN XML — GET REACT_APP_BACKEND_URL/v1/dso/toepasbare-regels/{dmnId}/dmn (?env=prod only when env=prod);
  2. prefills dmnData with fileName: decision-{dmnId}.dmn, the fetched XML, and the primary decision key — keeping the tab interactive (isImported: false, not the preserved mode);
  3. prefills the Service tab (title/identifier/description from the DSO activity) and Organization tab (from the resolved authority);
  4. strips the import params via history.replaceState so a refresh can't re-import.

Deploy + test + publish then run through the existing DMNTab / PublishDialog flow. DMNTab hydrates its internal uploaded-file/decision-key/test-body/validation state from dmnData.content whenever content arrives from outside the tab and no local file was uploaded — without this, deploy/test stayed gated on the internal uploadedFile state.


Operaton REST API

Deploy endpoint:

POST /engine-rest/deployment/create
Content-Type: multipart/form-data
Body: deployment-name={serviceId}, upload={dmnFile}

Evaluate endpoint:

POST /engine-rest/decision-definition/key/{decisionKey}/evaluate
Content-Type: application/json
Body: { "variables": { ... } }

URI generation

DMN URIs are derived from the service identifier. Given service.identifier = "aow-leeftijd":

<https://regels.overheid.nl/services/aow-leeftijd/dmn>
    a cprmv:DecisionModel .

<https://regels.overheid.nl/services/aow-leeftijd/dmn/input/1>
    a cpsv:Input .

<https://regels.overheid.nl/services/aow-leeftijd/rules/DecisionRule_2020>
    a cpsv:Rule, cprmv:DecisionRule .

Spaces in the service identifier are replaced with hyphens. Full URIs in the organisation field are used as-is; short IDs are expanded.


Shell script parity

The intermediate tests and test cases features in the UI mirror the shell scripts in examples/organizations/*/:

Shell script UI equivalent
test-dmn-zorgtoeslag.sh DMN Tab → Intermediate Decision Tests
test-cases-zorgtoeslag.sh DMN Tab → Test Cases

The shell scripts can still be used for CI/CD automation independently of the UI.