Skip to content

Commit 89cd85a

Browse files
CopilotSimonLoir
andauthored
feat: add Kosit external validator for UBL XML validation (#7)
* Initial plan * feat: add Kosit validator for external XML validation service Agent-Logs-Url: https://github.com/PixelDrive/peppol-toolkit/sessions/854517cf-c37d-43ea-b2c5-257948de8aba Co-authored-by: SimonLoir <17649337+SimonLoir@users.noreply.github.com> * fix: address code review feedback for Kosit validator Agent-Logs-Url: https://github.com/PixelDrive/peppol-toolkit/sessions/854517cf-c37d-43ea-b2c5-257948de8aba Co-authored-by: SimonLoir <17649337+SimonLoir@users.noreply.github.com> * fix: update Kosit validator to handle HTTP 406 status and clean up code formatting * chore: bump version from 0.5.2 to 0.6.0 in package.json and package-lock.json * docs: update README to include KoSIT validation feature and usage examples --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SimonLoir <17649337+SimonLoir@users.noreply.github.com> Co-authored-by: Simon Loir <simon-loir@hotmail.com>
1 parent da71863 commit 89cd85a

7 files changed

Lines changed: 421 additions & 5 deletions

File tree

README.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ PEPPOL (Pan-European Public Procurement On-Line) is a set of specifications that
77
## Features
88

99
- 🚀 Generate PEPPOL-compliant UBL XML invoices
10+
- ✅ Validate UBL XML against a KoSIT validator service
1011
- 📦 ESM and CommonJS builds for broad compatibility
1112
- 🔷 Written in TypeScript with bundled type definitions
1213
- 🧪 Built with fast-xml-parser for reliable XML generation
@@ -307,6 +308,31 @@ const creditNoteXml = toolkit.creditNoteToPeppolUBL(exampleCreditNote);
307308
console.log(creditNoteXml);
308309
```
309310

311+
### Validate UBL XML with KoSIT
312+
313+
You can validate generated (or external) UBL XML through a running KoSIT validator service.
314+
315+
```typescript
316+
import { PeppolToolkit, exampleInvoice } from '@pixeldrive/peppol-toolkit';
317+
318+
const toolkit = new PeppolToolkit();
319+
const xml = toolkit.invoiceToPeppolUBL(exampleInvoice);
320+
321+
const result = await toolkit.validateWithKosit(xml, {
322+
endpoint: 'http://localhost:8081/',
323+
});
324+
325+
console.log(result.valid); // true | false
326+
console.log(result.errors); // validation errors
327+
console.log(result.warnings); // validation warnings
328+
```
329+
330+
Notes:
331+
332+
- Default endpoint is `http://localhost:8081/`.
333+
- Requests are sent as `POST` with `Content-Type: application/xml`.
334+
- Some KoSIT deployments can return HTTP `406` together with an XML report for invalid documents; this toolkit still parses that report and returns structured `errors`/`warnings`.
335+
310336
## API Reference
311337

312338
### `PeppolToolkit`
@@ -321,6 +347,7 @@ The main class that provides invoice and credit note conversion functionality.
321347
| `creditNoteToPeppolUBL(creditNote: CreditNote): string` | Converts a `CreditNote` object to a PEPPOL-compliant UBL XML string |
322348
| `peppolUBLToInvoice(xml: string): Invoice` | Parses a PEPPOL UBL XML string into an `Invoice` object |
323349
| `peppolUBLToCreditNote(xml: string): CreditNote` | Parses a PEPPOL UBL XML string into a `CreditNote` object |
350+
| `validateWithKosit(xml: string, options?: KositValidatorOptions): Promise<KositValidationResult>` | Sends XML to a KoSIT validator service and returns parsed validation outcome |
324351

325352
#### Static Helpers
326353

@@ -339,6 +366,15 @@ import { createToolkit } from '@pixeldrive/peppol-toolkit';
339366
const toolkit = createToolkit();
340367
```
341368

369+
### `KositValidator` and Related Types
370+
371+
Also exported for direct use:
372+
373+
- `KositValidator`
374+
- `KositValidatorOptions`
375+
- `KositValidationResult`
376+
- `KositValidationMessage`
377+
342378
## PEPPOL BIS UBL Invoice Elements Checklist
343379

344380
This toolkit supports the following PEPPOL BIS UBL invoice elements based on the current implementation:
@@ -456,13 +492,13 @@ Starting from version 1.0.0, this library will follow [Semantic Versioning (SemV
456492
- [x] Support CreditNote documents
457493
- [ ] Implement UBL 2.1 schema validation (offline)
458494
- [ ] Implement PEPPOL BIS profile validation (offline)
459-
- [ ] Enable online validation against remote services
495+
- [x] Enable online validation against remote services
460496
- [ ] Support attachments/binary objects embedding (e.g., PDF)
461497
- [ ] CLI: Convert JSON invoices to UBL XML
462498
- [x] Documentation: Examples and recipe-style guides
463499
- [ ] QA: Expand unit tests
464500

465-
Last updated: 2026-03-10
501+
Last updated: 2026-03-31
466502

467503
## Development Scripts
468504

package-lock.json

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pixeldrive/peppol-toolkit",
3-
"version": "0.5.2",
3+
"version": "0.6.0",
44
"description": "A TypeScript toolkit for building and reading peppol UBL documents",
55
"keywords": [
66
"typescript",

src/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ import { DocumentBuilder } from './builder';
22
import { DocumentParser } from './parser';
33
import { CreditNote, getEASFromTaxId, Invoice } from './documents';
44
import { computeTotals } from './helpers/computeTotals';
5+
import {
6+
KositValidator,
7+
KositValidatorOptions,
8+
KositValidationResult,
9+
} from './validator';
510

611
export class PeppolToolkit {
712
private __builder = new DocumentBuilder();
813
private __parser = new DocumentParser();
14+
private __kositValidator: KositValidator | null = null;
915

1016
public invoiceToPeppolUBL(invoice: Invoice): string {
1117
return this.__builder.generatePeppolInvoice(invoice);
@@ -23,6 +29,22 @@ export class PeppolToolkit {
2329
return this.__parser.parseCreditNote(xml);
2430
}
2531

32+
/**
33+
* Validates a UBL XML document using the Kosit validator service.
34+
* @param xml The XML document to validate.
35+
* @param options Optional configuration for the validator endpoint.
36+
* @returns The validation result including validity status, errors, and warnings.
37+
*/
38+
public async validateWithKosit(
39+
xml: string,
40+
options?: KositValidatorOptions
41+
): Promise<KositValidationResult> {
42+
const validator = options
43+
? new KositValidator(options)
44+
: (this.__kositValidator ??= new KositValidator());
45+
return await validator.validate(xml);
46+
}
47+
2648
public static computeTotals = computeTotals;
2749
public static getEASFromTaxId = getEASFromTaxId;
2850
}
@@ -40,5 +62,6 @@ export default {
4062
export * from './documents';
4163
export * from './builder';
4264
export * from './parser';
65+
export * from './validator';
4366
export { basicInvoice as exampleInvoice } from './data/basic-invoice';
4467
export { basicCreditNote as exampleCreditNote } from './data/basic-creditNote';

src/validator/KositValidator.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { XMLParser } from 'fast-xml-parser';
2+
3+
export interface KositValidatorOptions {
4+
/**
5+
* The URL of the Kosit validator service.
6+
* @default 'http://localhost:8081/'
7+
*/
8+
endpoint?: string;
9+
}
10+
11+
export interface KositValidationMessage {
12+
id: string;
13+
text: string;
14+
location: string;
15+
}
16+
17+
export interface KositValidationResult {
18+
valid: boolean;
19+
errors: KositValidationMessage[];
20+
warnings: KositValidationMessage[];
21+
rawXml: string;
22+
}
23+
24+
export class KositValidator {
25+
private readonly endpoint: string;
26+
private readonly parser: XMLParser;
27+
28+
constructor(options?: KositValidatorOptions) {
29+
this.endpoint = options?.endpoint ?? 'http://localhost:8081/';
30+
this.parser = new XMLParser({
31+
ignoreAttributes: false,
32+
attributeNamePrefix: '$',
33+
parseTagValue: false,
34+
removeNSPrefix: true,
35+
});
36+
}
37+
38+
/**
39+
* Validates a UBL XML document against the Kosit validator service.
40+
* @param xml The XML document to validate.
41+
* @returns The validation result including validity status, errors, and warnings.
42+
*/
43+
public async validate(xml: string): Promise<KositValidationResult> {
44+
const response = await fetch(this.endpoint, {
45+
method: 'POST',
46+
headers: {
47+
'Content-Type': 'application/xml',
48+
},
49+
body: xml,
50+
});
51+
52+
if (!response.ok && response.status !== 406) {
53+
throw new Error(
54+
`Kosit validator returned HTTP ${response.status}: ${response.statusText}`
55+
);
56+
}
57+
58+
const rawXml = await response.text();
59+
return this.parseReport(rawXml);
60+
}
61+
62+
private parseReport(rawXml: string): KositValidationResult {
63+
const parsed = this.parser.parse(rawXml) as Record<string, unknown>;
64+
65+
const report = (parsed['report'] ?? parsed['createReportInput']) as
66+
| Record<string, unknown>
67+
| undefined;
68+
69+
if (!report) {
70+
throw new Error(
71+
'Unexpected Kosit validator response: missing report element'
72+
);
73+
}
74+
75+
const assessment = report['assessment'] as
76+
| Record<string, unknown>
77+
| undefined;
78+
79+
const valid = assessment
80+
? 'accept' in assessment
81+
: this.inferValidityFromResults(report);
82+
83+
const errors: KositValidationMessage[] = [];
84+
const warnings: KositValidationMessage[] = [];
85+
86+
this.extractMessages(report, errors, warnings);
87+
88+
return { valid, errors, warnings, rawXml };
89+
}
90+
91+
private inferValidityFromResults(report: Record<string, unknown>): boolean {
92+
const scenarioMatched = report['scenarioMatched'] as
93+
| Record<string, unknown>
94+
| undefined;
95+
96+
if (!scenarioMatched) {
97+
return false;
98+
}
99+
100+
const steps = scenarioMatched['validationStepResult'];
101+
const stepArray = Array.isArray(steps) ? steps : steps ? [steps] : [];
102+
103+
return stepArray.every((step: Record<string, unknown>) => {
104+
const recommendation = step['recommendation'] as string | undefined;
105+
return recommendation === 'accept';
106+
});
107+
}
108+
109+
private extractMessages(
110+
report: Record<string, unknown>,
111+
errors: KositValidationMessage[],
112+
warnings: KositValidationMessage[]
113+
): void {
114+
const scenarioMatched = report['scenarioMatched'] as
115+
| Record<string, unknown>
116+
| undefined;
117+
118+
if (!scenarioMatched) {
119+
return;
120+
}
121+
122+
const steps = scenarioMatched['validationStepResult'];
123+
const stepArray = Array.isArray(steps) ? steps : steps ? [steps] : [];
124+
125+
for (const step of stepArray as Record<string, unknown>[]) {
126+
this.extractStepMessages(step, errors, warnings);
127+
}
128+
}
129+
130+
private extractStepMessages(
131+
step: Record<string, unknown>,
132+
errors: KositValidationMessage[],
133+
warnings: KositValidationMessage[]
134+
): void {
135+
const messages = step['message'];
136+
const messageArray = Array.isArray(messages)
137+
? messages
138+
: messages
139+
? [messages]
140+
: [];
141+
142+
for (const msg of messageArray as Record<string, unknown>[]) {
143+
const entry: KositValidationMessage = {
144+
id: String(msg['$id'] ?? msg['id'] ?? ''),
145+
text: String(msg['#text'] ?? msg['text'] ?? ''),
146+
location: String(msg['$location'] ?? msg['location'] ?? ''),
147+
};
148+
149+
const level = String(
150+
msg['$level'] ?? msg['level'] ?? ''
151+
).toLowerCase();
152+
153+
if (level === 'warning') {
154+
warnings.push(entry);
155+
} else {
156+
errors.push(entry);
157+
}
158+
}
159+
}
160+
}

src/validator/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './KositValidator';

0 commit comments

Comments
 (0)