Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
/.gitignore export-ignore
/CHANGELOG.md export-ignore
/CONTRIBUTING.md export-ignore
/doc export-ignore
/docs export-ignore
/tools export-ignore
/Makefile export-ignore
/phpunit.xml.dist export-ignore
/phpunit.xml export-ignore
/tests export-ignore
/VERSIONING.md export-ignore
/.php-cs-fixer.php export-ignore
/rector.php export-ignore
/phpstan.neon.dist export-ignore
/phpstan-baseline.neon export-ignore
/phpstan-baseline.neon export-ignore
/Dockerfile export-ignore
26 changes: 26 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,32 @@ jobs:
with:
php-version: ${{ matrix.php-versions }}

- name: Cache Saxon HE
uses: actions/cache@v5
id: saxon-cache
with:
path: saxon
key: saxon-he-12.9

- name: Download Saxon HE 12.9
id: saxon_download
if: steps.saxon-cache.outputs.cache-hit != 'true'
run: |
SAXON_ZIP_URL="https://github.com/Saxonica/Saxon-HE/releases/download/SaxonHE12-9/SaxonHE12-9J.zip"
curl -L -o saxon.zip "$SAXON_ZIP_URL"
unzip -q saxon.zip -d saxon
rm saxon.zip

- name: Set Saxon JAR path
run: |
SAXON_JAR=$(find saxon -type f -name "saxon-he-12.9.jar" | head -n 1)
if [ -z "$SAXON_JAR" ]; then
echo "⚠️ JAR Saxon not found!"
exit 1
fi
echo "SAXON_JAR=$SAXON_JAR" >> $GITHUB_ENV
echo "Saxon JAR path: $SAXON_JAR"

- uses: "ramsey/composer-install@v3"

- name: Run test suite
Expand Down
36 changes: 36 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Use PHP 8.3 CLI as base image
FROM php:8.3-cli

# Install system dependencies and PHP extensions
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
unzip \
curl \
ca-certificates \
libzip-dev \
libicu-dev \
libxml2-dev \
default-jre \
wget \
&& docker-php-ext-install intl zip dom xml \
&& rm -rf /var/lib/apt/lists/*

# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

# Set working directory
WORKDIR /app

# Install Saxon Home edition for Java
RUN wget -nv "https://github.com/Saxonica/Saxon-HE/releases/download/SaxonHE12-9/SaxonHE12-9J.zip" -O /tmp/saxon.zip && \
unzip /tmp/saxon.zip -d /opt/saxon && \
rm /tmp/saxon.zip

ENV PATH="/opt/saxon/bin:${PATH}"

ENV SAXON_JAR="/opt/saxon/saxon-he-12.9.jar"




Comment thread
qdequippe marked this conversation as resolved.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"friendsofphp/php-cs-fixer": "^3.93",
"phpunit/phpunit": "^12.5",
"ext-libxml": "*",
"ext-dom": "*"
"ext-dom": "*",
"symfony/process": "^7.4"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normal que ce soit qu'en require-dev ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oui cela permet d'utiliser la lib même si on n'a pas besoin du validateur de schematron (et donc de symfony/process)

https://github.com/Tiime-PDP/cdar/pull/11/changes#diff-bbb26dd881ffa33ff00c5ee99346f31d396b2ce6040c6f38759bc8506e1bace1R16-R17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Et du coup pourquoi avoir mis le schematron validator dans la lib et pas dans charon, pour Tiime ? parce qu'on a besoin du schematron validator pour d'autre flux que les F6 ... On ne va pas le mettre dans toutes les libs ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oui c'est pour l'usage côté Tiime, maintenant il est vrai qu'extraire une lib dédiée indépendante pour la validation de schematron peut etre une idée intéressante dans le futur.

},
"scripts": {
"cs-fix": "php-cs-fixer fix",
Expand Down
Empty file removed docs/.gitkeep
Empty file.
80 changes: 80 additions & 0 deletions docs/SaxonJarSchematronValidator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# SaxonJarSchematronValidator

The `SaxonJarSchematronValidator` is a Schematron validator implementation that uses the Saxon XSLT processor (Java edition) to validate XML documents against Schematron rules compiled as XSLT stylesheets.

## Overview

Schematron is a rule-based validation language for XML documents. Unlike XSD (XML Schema Definition), which validates structure, Schematron validates business rules and constraints using XPath expressions.

In this implementation:
1. Schematron rules are pre-compiled into an XSLT stylesheet
2. Saxon XSLT processor transforms the XML document using the XSLT rules
3. The output is an SVRL (Schematron Validation Report Language) document
4. Failed assertions are extracted and reported as validation errors

## Requirements

### System Requirements

- **Java Runtime Environment (JRE)** 8 or higher
- The `java` command must be available in your system PATH
- Check with: `java -version`

- **Saxon-HE JAR file** (Home Edition, version 9.x or higher)
- Download from: [Saxonica Downloads](https://www.saxonica.com/download/java.xml)

### PHP Requirements

- **PHP 8.3** or higher
- **Required PHP extensions:**
- `dom` - for parsing SVRL XML output
- `libxml` - for XML handling

### Composer Dependencies

```bash
composer require symfony/process
```

The `symfony/process` component is used to execute the Java Saxon processor as a subprocess.

## Usage

### Basic Usage

```php
<?php

use TiimePDP\CrossDomainAcknowledgementAndResponse\Schematron\SaxonJarSchematronValidator;
use TiimePDP\CrossDomainAcknowledgementAndResponse\Schematron\ValidationFailedException;

// Create validator instance with path to Saxon JAR
$validator = new SaxonJarSchematronValidator(
saxonJar: '/usr/local/lib/saxon-he.jar'
);

try {
// Validate XML file against Schematron XSLT rules
$validator->validate(
xmlFilepath: '/path/to/document.xml',
xsltFilepath: '/path/to/schematron-rules.xsl'
);

echo "Validation successful!\n";

} catch (ValidationFailedException $e) {
echo "Validation failed: " . $e->getMessage() . "\n";

// Access validation errors
foreach ($e->errors as $error) {
echo sprintf(
"Error [%s]: %s\n Location: %s\n Test: %s\n",
$error->getId(),
$error->getText(),
$error->getLocation(),
$error->getTest()
);
}
}
```

99 changes: 99 additions & 0 deletions src/Schematron/SaxonJarSchematronValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace TiimePDP\CrossDomainAcknowledgementAndResponse\Schematron;

use Symfony\Component\Process\Process;

final readonly class SaxonJarSchematronValidator implements SchematronValidatorInterface
{
/**
* @throws \LogicException
*/
public function __construct(private string $saxonJar)
{
if (false === class_exists(Process::class)) {
throw new \LogicException('Symfony Process component is required to use SaxonJarSchematronValidator. Run "composer require symfony/process"');
}

if (false === extension_loaded('dom') || false === extension_loaded('libxml')) {
throw new \LogicException('DOM and Libxml extensions are required to validate business rules.');
}
Comment thread
qdequippe marked this conversation as resolved.

if (false === is_file($this->saxonJar) || false === is_readable($this->saxonJar)) {
throw new \LogicException(sprintf('Saxon JAR file "%s" does not exist or is not readable.', $this->saxonJar));
}
}

public function validate(string $xmlFilepath, string $xsltFilepath): void
{
$process = new Process([
'java',
'-jar',
$this->saxonJar,
'-s:'.$xmlFilepath,
'-xsl:'.$xsltFilepath,
]);
Comment thread
qdequippe marked this conversation as resolved.

$process->setTimeout(3600);
Comment thread
qdequippe marked this conversation as resolved.
$process->run();

if (false === $process->isSuccessful()) {
throw new ValidationFailedException(message: $process->getErrorOutput(), code: $process->getExitCode() ?? 1);
}

$output = trim($process->getOutput());

$doc = new \DOMDocument();

try {
Comment thread
qdequippe marked this conversation as resolved.
if (false === $doc->loadXML($output)) {
throw new ValidationFailedException(message: 'Failed to parse Schematron validation output: invalid XML.');
}
} catch (\Throwable $throwable) {
throw new ValidationFailedException(message: 'Failed to parse Schematron validation output: '.$throwable->getMessage(), previous: $throwable);
}

$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('svrl', 'http://purl.oclc.org/dsdl/svrl');

$failedAsserts = $xpath->query('//svrl:failed-assert');

if (false === $failedAsserts) {
throw new ValidationFailedException(message: 'Failed to parse Schematron validation output');
}

$errors = [];

/** @var \DOMNode $fa */
foreach ($failedAsserts as $fa) {
if (!$fa instanceof \DOMElement) {
continue;
}

$location = $fa->getAttribute('location');
$test = $fa->getAttribute('test');

$text = null;
$textElements = $fa->getElementsByTagName('text');
if (0 !== $textElements->length && $textElements->item(0) instanceof \DOMElement) {
$text = $textElements->item(0)->nodeValue;
}

$errors[] = new ValidationError(
test: $test,
id: $fa->getAttribute('id'),
flag: $fa->getAttribute('flag'),
location: $location,
text: $text,
);
}

Comment thread
qdequippe marked this conversation as resolved.
if ([] === $errors) {
return;
}

throw new ValidationFailedException(errors: $errors, message: 'Schematron validation failed');
}
}
16 changes: 16 additions & 0 deletions src/Schematron/SchematronValidatorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace TiimePDP\CrossDomainAcknowledgementAndResponse\Schematron;

interface SchematronValidatorInterface
{
/**
* @param string $xmlFilepath the path to the XML file to validate against the business rules
* @param string $xsltFilepath the path to the Schematron XSLT file to use for validation
*
* @throws ValidationFailedException
*/
public function validate(string $xmlFilepath, string $xsltFilepath): void;
}
57 changes: 57 additions & 0 deletions src/Schematron/ValidationError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace TiimePDP\CrossDomainAcknowledgementAndResponse\Schematron;

final readonly class ValidationError
Comment thread
qdequippe marked this conversation as resolved.
{
private string $test;

private string $id;

private string $flag;

private string $location;

private ?string $text;

public function __construct(
string $test,
string $id,
string $flag,
string $location,
?string $text,
) {
$this->test = $test;
$this->id = $id;
$this->flag = $flag;
$this->location = $location;
$this->text = $text;
}
Comment thread
qdequippe marked this conversation as resolved.

public function getTest(): string
{
return $this->test;
}

public function getId(): string
{
return $this->id;
}

public function getFlag(): string
{
return $this->flag;
}

public function getLocation(): string
{
return $this->location;
}

public function getText(): ?string
{
return $this->text;
}
}
20 changes: 20 additions & 0 deletions src/Schematron/ValidationFailedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace TiimePDP\CrossDomainAcknowledgementAndResponse\Schematron;

final class ValidationFailedException extends \RuntimeException
Comment thread
qdequippe marked this conversation as resolved.
{
/**
* @param ValidationError[] $errors
*/
public function __construct(
public readonly array $errors = [],
string $message = '',
int $code = 0,
?\Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
}
Loading
Loading