Skip to content
Open
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"




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"
},
"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()
);
}
}
```

95 changes: 95 additions & 0 deletions src/Schematron/SaxonJarSchematronValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?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.');
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

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

The validator does not check if the Saxon JAR file exists and is readable before attempting to execute it. If the file doesn't exist or isn't accessible, the error will only surface when the Process runs, leading to a less clear error message. Consider adding a validation check in the constructor to verify the Saxon JAR file exists and is readable, throwing a clear \LogicException if not.

Suggested change
}
}
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));
}

Copilot uses AI. Check for mistakes.
}

public function validate(string $xmlFilepath, string $xsltFilepath): void
{
$process = new Process([
'java',
'-jar',
$this->saxonJar,
'-s:'.$xmlFilepath,
'-xsl:'.$xsltFilepath,
]);
Comment on lines +25 to +33
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

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

The validate method does not check if the input XML file and XSLT file exist and are readable before passing them to the Saxon process. If either file doesn't exist, Saxon will fail with potentially unclear error messages. Consider adding file existence checks at the beginning of the validate method and throwing a clear exception (e.g., \InvalidArgumentException) if files are missing or not readable.

Copilot uses AI. Check for mistakes.

$process->setTimeout(3600);
$process->run();

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

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

$doc = new \DOMDocument();

try {
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,
);
}

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
{
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 on lines +7 to +31
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

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

The ValidationError class lacks PHPDoc comments for its properties and methods, which is inconsistent with the SchemaValidationError class in the same codebase that includes detailed PHPDoc for all properties. For consistency and better IDE support, add PHPDoc comments describing what each property represents (test, id, flag, location, text).

Copilot uses AI. Check for mistakes.

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
{
/**
* @param ValidationError[] $errors
*/
public function __construct(
public readonly array $errors = [],
string $message = '',
int $code = 0,
?\Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
}
Loading