First draft

This commit is contained in:
2026-04-30 16:08:01 +02:00
parent 7ce97c0c55
commit e09a9fc2a6
21 changed files with 7137 additions and 0 deletions

23
composer.json Normal file
View File

@@ -0,0 +1,23 @@
{
"autoload" : {
"psr-4" : {
"App\\" : "src/"
}
},
"require": {
"symfony/http-foundation": "^6.1",
"symfony/http-kernel": "^6.1",
"symfony/routing": "^6.1",
"symfony/dependency-injection": "^6.1",
"symfony/string": "^6.1",
"symfony/serializer": "^7.1",
"symfony/property-access": "^7.1",
"symfony/config": "^7.3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.64",
"phpstan/phpstan": "^1.12",
"rector/rector": "^1.2",
"phpunit/phpunit": "^10.5.63"
}
}

5686
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use App\Enum\OaiVerbs;
use App\OAI\OaiError;
use App\OAI\Verb\{
GetRecord,
Identify,
ListRecords,
ListIdentifiers,
ListSets,
ListMetadataFormats,
};
final class OaiController
{
#[Route('/oai', methods: 'get')]
public function index(Request $request): Response
{
$res = new Response();
$res->headers->set('Content-Type', 'text/xml');
$query = $request->getQueryString();
$verb = $request->get('verb');
if ($query === null || $verb === null) {
$error = new OaiError('badVerb', 'The verb argument is missing');
$res->setContent($error->toXML());
return $res;
}
$validVerb = OaiVerbs::tryFrom($verb);
if (! $validVerb) {
$error = new OaiError('badVerb', "'{$verb}' is not a valid OAI-PMH verb");
$res->setContent($error->toXML());
return $res;
}
$mdPrefix = $request->get('metadataPrefix');
$resumptionToken = $request->get('resumptionToken');
if (
($validVerb === OaiVerbs::ListRecords
|| $validVerb === OaiVerbs::ListIdentifiers
|| $validVerb === OaiVerbs::GetRecord) &&
($mdPrefix === null && $resumptionToken === null)
) {
$error = new OaiError('badArgument', 'The \'metadataPrefix\' argument is missing.');
$res->setContent($error->toXML());
return $res;
}
return match ($validVerb->value) {
'Identify' => $this->identify($res, (new Identify)->response()),
'GetRecord' => $this->getRecord($request, $res),
'ListRecords' => $this->listRecords($request, $res),
'ListSets' => $this->listSets($res),
'ListMetadataFormats' => $this->listMetadataFormats($res),
'ListIdentifiers' => $this->listIdentifiers($request, $res),
};
}
private function identify(Response $res, string $content): Response
{
$res->setContent($content);
return $res;
}
private function getRecord(Request $req, Response $res): Response
{
$identifier = $req->get('identifier');
if (! $this->checkIdentifier($identifier)) {
$error = new OaiError('idDoesNotExist', "The identifier '$identifier' is illegal.");
$res->setContent($error->toXML());
return $res;
}
$format = $req->get('metadataPrefix');
$res->setContent((new GetRecord($identifier, $format))->response());
return $res;
}
public function listRecords(Request $req, Response $res): Response
{
$records = (new ListRecords($req))->response();
$res->setContent($records);
return $res;
}
private function checkIdentifier(string $identifier): bool
{
$pattern = "/oai:www.yourdomain.com:(article|model|image):\d+$/";
return (bool)preg_match($pattern, $identifier);
}
public function listSets(Response $res): Response
{
$xml = (new ListSets)->response();
$res->setContent($xml);
return $res;
}
public function listMetadataFormats(Response $res): Response
{
$xml = (new ListMetadataFormats)->response();
$res->setContent($xml);
return $res;
}
public function listIdentifiers(Request $req, Response $res): Response
{
$xml = (new ListIdentifiers($req))->response();
$res->setContent($xml);
return $res;
}
}

17
src/Enum/OaiSetspec.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* @todo Modify according to sets in DB or remove
*/
enum OaiSetspec: int
{
case JournalArticles = 2;
case SupplementArticles = 5;
case SupplementBook = 6;
case Images = 8;
case Models = 9;
}

15
src/Enum/OaiVerbs.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum OaiVerbs: string
{
case Identify = 'Identify';
case ListRecords = 'ListRecords';
case ListIdentifiers = 'ListIdentifiers';
case ListSets = 'ListSets';
case ListMetadataFormats = 'ListMetadataFormats';
case GetRecord = 'GetRecord';
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\OAI\Exception;
final class IdentifierNotFoundException extends \Exception
{
public function __construct(string $identifier)
{
$this->message = "Identifier '{$identifier}' does not exist in this repository.";
$this->code = 'idDoesNotExist';
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\OAI\Exception;
final class InvalidFormatException extends \Exception
{
public function __construct()
{
$this->message = "The metadata format is not supported for this item.";
$this->code = 'cannotDisseminateFormat';
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\OAI\Exception;
final class InvalidTokenException extends \Exception
{
public function __construct()
{
$this->message = "The value of the resumptionToken argument is invalid or expired.";
$this->code = 'badResumptionToken';
}
}

71
src/OAI/OaiBase.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\OAI;
use \DOMDocument;
use function Symfony\Component\String\b;
/**
* Creates the base XML document containing dynamically generated responses
* @todo different create functions depending on request type? e.g. GetRecord
*/
final class OaiBase
{
/**
* @param string $content The dynamically generated XML response
* @param string $nodeName The root node name to be imported
*/
public static function create(string $content, string $nodeName, string $metadataFormat = 'oai_dc'): string
{
$main = new DOMDocument(encoding: 'UTF-8');
$main->formatOutput = true;
$xslt = $main->createProcessingInstruction('xml-stylesheet', 'type="text/xsl" href="/xslt/oai2.xsl"');
$main->appendChild($xslt);
$imported = new DOMDocument(encoding: 'UTF-8');
$imported->loadXML(b($content)->toUnicodeString()->toString(), LIBXML_NOWARNING);
$node = $imported->getElementsByTagName($nodeName)->item(0);
$root = $main->createElement('OAI-PMH');
$root->setAttribute('xmlns', 'http://www.openarchives.org/OAI/2.0/');
$root->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');
$root->setAttribute('xsi:schemaLocation', 'http://www.openarchives.org/OAI/2.0/ http://www.openarchives.org/OAI/2.0/OAI-PMH.xsd');
$responseDate = $main->createElement('responseDate', date("Y-m-d\TH:i:s\Z"));
$root->appendChild($responseDate);
$request = $main->createElement('request', 'https://www.yourdomain.com/oai');
if ($nodeName !== 'error') {
$request->setAttribute('verb', $nodeName);
// TODO dynamic metadata prefix!
$request->setAttribute('metadataPrefix', $metadataFormat);
}
$root->appendChild($request);
$main->appendChild($root);
$node = $main->importNode($node, true);
$root->appendChild($node);
// To avoid error when importing XML from RecordOaiDatacite... (why??)
if ($metadataFormat === 'oai_datacite') {
$oai_datacite = $main->getElementsByTagName('oai_datacite')->item(0);
$resource = $main->getElementsByTagName('resource')->item(0);
$xmlns = $main->createAttribute('xmlns');
$xsi = $main->createAttribute('xsi:schemaLocation');
$xmlnsRes = $main->createAttribute('xmlns');
$xsiRes = $main->createAttribute('xsi:schemaLocation');
$xsi->value = "http://schema.datacite.org/oai/oai-1.1/ http://schema.datacite.org/oai/oai-1.1/oai.xsd";
$xmlns->value = "http://schema.datacite.org/oai/oai-1.1/";
$oai_datacite->appendChild($xmlns);
$oai_datacite->appendChild($xsi);
$xmlnsRes->value = "http://datacite.org/schema/kernel-3";
$xsiRes->value = "http://datacite.org/schema/kernel-3 http://schema.datacite.org/meta/kernel-3/metadata.xsd";
$resource->appendChild($xmlnsRes);
$resource->appendChild($xsiRes);
}
return $main->saveXML();
}
}

72
src/OAI/OaiError.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\OAI;
/**
* @todo Use Symfony serializer to generate XML?
*/
use App\OAI\OaiBase;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter;
use Symfony\Component\Serializer\Attribute\SerializedName;
final class OaiError
{
#[SerializedName('@code')]
public string $code;
#[SerializedName('#')]
public string $description;
public function __construct(
$code,
$description,
private Serializer $serializer = new Serializer()
)
{
$this->code = $code;
$this->description = $description;
$classMetadataFactory = new ClassMetadataFactory(new AttributeLoader());
$metadataAwareNameConverter = new MetadataAwareNameConverter($classMetadataFactory);
$this->serializer = new Serializer(
[new ObjectNormalizer($classMetadataFactory, $metadataAwareNameConverter)],
[new XmlEncoder()]
);
}
public function setError(string $code, string $description)
{
$this->code = $code;
$this->description = $description;
}
/**
* Serialize object
* @todo Normalize to array then serialize to XML
*/
public function toXML(): string
{
$normal = $this->serializer->normalize($this);
$content = $this->serializer->encode(
$normal,
'xml',
[
'xml_format_output' => true,
'xml_root_node_name' => 'error',
// Don't include the XML declaration, handled by OaiBase
'encoder_ignored_node_types' => [
\XML_PI_NODE,
],
]);
return OaiBase::create($content, 'error');
}
}

66
src/OAI/OaiIdentify.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\OAI;
use AC\DB\Connection;
use App\OAI\RecordOaiInterface;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
/**
* Serializes an Identify response
*/
final class OaiIdentify implements RecordOaiInterface
{
public function __construct(
private Serializer $serializer = new Serializer(),
)
{
$this->serializer = new Serializer(
[new ObjectNormalizer()],
[new XmlEncoder()]
);
}
/**
* This should get literal values from settings / config
*/
public function toXML(): string
{
$datestamp = $this->getEarliestDatestamp();
$response = [
'repositoryName' => 'Your bloody repo name',
'baseURL' => "https://www.yourdomain.com/oai",
'protocolVersion' => 2.0,
'adminEmail' => 'crazyadmin@yourdomain.com',
'earliestDatestamp' => $datestamp,
'deletedRecord' => 'no',
'granularity' => 'YYYY-MM-DD',
];
$content = $this->serializer->encode(
$response,
'xml',
[
'xml_format_output' => true,
'xml_root_node_name' => 'Identify',
// Don't include the XML declaration, handled by OaiBase
'encoder_ignored_node_types' => [
\XML_PI_NODE,
],
]
);
return $content;
}
/**
* This should return the earliest datestamp from the repo's database
* considering all available OAI resources
*/
private function getEarliestDatestamp(): string
{
// Implement
return date('YYYY-MM-DD');
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\OAI;
use App\OAI\RecordOaiInterface;
use App\OAI\Exception\IdentifierNotFoundException;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter;
use Symfony\Component\Serializer\Attribute\SerializedName;
/**
* Serializes a record (DB data) in the `oai_dacite` metadata format
*/
final class RecordOaiDataCite implements RecordOaiInterface
{
private string $identifier;
private string $verb;
public array $titles;
public array $creators;
public array $rightsList;
public array $descriptions;
public array $dates;
public int $publicationYear;
public string $language;
public array $resourceType;
public array $subjects;
#[SerializedName('identifier')]
public array $oaiIdentifier;
public array $alternateIdentifiers;
public array $formats;
public array|string $source;
public function __construct(
string $identifier,
string $verb = 'GetRecord',
private Serializer $serializer = new Serializer(),
)
{
$this->verb = $verb;
$this->identifier = $identifier;
preg_match("/oai:www.yourdomain.com(:|\/)(?P<res>.*):(?P<id>\d+)$/", $identifier, $matches);
$classMetadataFactory = new ClassMetadataFactory(new AttributeLoader());
$metadataAwareNameConverter = new MetadataAwareNameConverter($classMetadataFactory);
$this->serializer = new Serializer(
[new ObjectNormalizer($classMetadataFactory, $metadataAwareNameConverter)],
[new XmlEncoder()]
);
}
/**
* Map DB record data to object attributes for serialization
*/
private function mapData(array $record): void
{
// Implement
}
/**
* Return an XML representation of a DB record
* in a given format
* @throws IdentifierNotFoundException
*/
public function toXML(): string
{
$record = []; // This represents a record from the database
if ($record === null) {
throw new IdentifierNotFoundException($this->identifier);
}
$this->mapData($record);
$normal = $this->serializer->normalize($this);
$data = $normal;
$data = $this->buildRelated($record, $data);
$oai_datacite = [
'oai_datacite' => [
['resource' => $data]
]
];
$header = [
'identifier' => $this->identifier,
'datestamp' => $record['datestamp'],
'setSpec' => $record['setSpec'],
];
// Adapt for ListRecords...
$record = $this->verb === 'GetRecord' ?
['record' => ['header' => $header, 'metadata' => $oai_datacite]] :
['header' => $header, 'metadata' => $oai_datacite];
$content = $this->serializer->encode(
$record,
'xml',
[
'xml_format_output' => true,
'xml_root_node_name' => $this->verb === 'GetRecord' ? $this->verb : 'record',
// Don't include the XML declaration, handled by OaiBase
'encoder_ignored_node_types' => [
\XML_PI_NODE,
],
]
);
return $content;
}
/**
* Example of creating related identifiers for a DataCite record
*/
private function buildRelated(array $record, array $data)
{
if (str_contains($this->identifier, 'image')) {
$models = $record['relation']['related'];
$relModels = [];
foreach ($models as $mod) {
array_push(
$relModels,
[
'@relatedIdentifierType' => 'URL',
'@relationType' => 'IsDerivedFrom',
'#' => $mod
]
);
}
$data['#']['relatedIdentifiers']['relatedIdentifier'] = [
[
'@relatedIdentifierType' => 'DOI',
'@relationType' => 'IsPartOf',
'#' => preg_replace("/^info(.*)(10.*$)/i", '$2', $record['relation'][1]),
],
...$relModels
];
}
return $data;
}
}

123
src/OAI/RecordOaiDc.php Normal file
View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\OAI;
use App\OAI\RecordOaiInterface;
use App\OAI\Exception\IdentifierNotFoundException;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter;
use Symfony\Component\Serializer\Attribute\SerializedName;
/**
* Serializes a record (DB data) in the `oai_dc` metadata format
* @class RecordOaiDc
*/
final class RecordOaiDc implements RecordOaiInterface
{
private int $id;
private string $identifier;
private string $verb;
#[SerializedName('dc:title')]
public string $title;
#[SerializedName('dc:rights')]
public array $rights;
#[SerializedName('dc:description')]
public ?string $description;
#[SerializedName('dc:date')]
public int $date;
#[SerializedName('dc:language')]
public string $language;
#[SerializedName('dc:type')]
public array $recordType;
#[SerializedName('dc:subject')]
public array $subject;
#[SerializedName('dc:creator')]
public $creator;
#[SerializedName('dc:identifier')]
public array $oaiIdentifier;
#[SerializedName('dc:format')]
public string $format;
#[SerializedName('dc:source')]
public array|string $source;
public function __construct(
string $identifier,
string $verb = 'GetRecord',
private Serializer $serializer = new Serializer(),
)
{
$this->verb = $verb;
$this->identifier = $identifier;
preg_match("/oai:www.yourdomain.com(:|\/)(?P<res>.*):(?P<id>\d+)$/", $identifier, $matches);
$classMetadataFactory = new ClassMetadataFactory(new AttributeLoader());
$metadataAwareNameConverter = new MetadataAwareNameConverter($classMetadataFactory);
$this->serializer = new Serializer(
[new ObjectNormalizer($classMetadataFactory, $metadataAwareNameConverter)],
[new XmlEncoder()]
);
}
/**
* Map DB record data to object attributes for serialization
*/
private function mapData(array $record): void
{
// Implement
}
/**
* Return an XML representation of a DB record
* in a given format
*/
public function toXML(): string
{
$record = []; // This represents a record from the database
if ($record === null) {
throw new IdentifierNotFoundException($this->identifier);
}
$this->mapData($record);
$normal = $this->serializer->normalize($this);
$data = [
'@xmlns:oai_dc' => "http://www.openarchives.org/OAI/2.0/oai_dc/",
'@xmlns:dc' => "http://purl.org/dc/elements/1.1/",
'#' => $normal
];
$oai_dc = ['oai_dc:dc' => $data];
if (isset($record['relation'])) {
$oai_dc['oai_dc:dc']['dc:relation'] = $record['relation'];
}
$header = [
'identifier' => $this->identifier,
'datestamp' => $record['datestamp'],
'setSpec' => $record['setSpec'],
];
// Adapt for ListRecords...
$record = $this->verb === 'GetRecord' ?
['record' => ['header' => $header, 'metadata' => $oai_dc]] :
['header' => $header, 'metadata' => $oai_dc];
$content = $this->serializer->encode(
$record,
'xml',
[
'xml_format_output' => true,
'xml_root_node_name' => $this->verb === 'GetRecord' ? $this->verb : 'record',
// Don't include the XML declaration, handled by OaiBase
'encoder_ignored_node_types' => [
\XML_PI_NODE,
],
]
);
return $content;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\OAI;
interface RecordOaiInterface
{
public function toXML(): string;
}

185
src/OAI/ResumptionToken.php Normal file
View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\OAI;
use App\OAI\Exception\InvalidTokenException;
use DateTime;
use DateTimeImmutable;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use \Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use \Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter;
final class ResumptionToken
{
#[SerializedName('@cursor')]
public readonly int $cursor;
#[SerializedName('@completeListSize')]
public readonly int $size;
#[Ignore]
public readonly \DateTimeImmutable $validity;
#[SerializedName('@expirationDate')]
public readonly string $expirationDate;
#[Ignore]
public readonly string $metadataFormat;
private Serializer $serializer;
#[Ignore]
public readonly ?string $set;
#[Ignore]
public readonly ?string $from;
#[Ignore]
public readonly ?string $until;
public function __construct(
int $cursor,
int $size,
string $metadataFormat = 'oai_dc', // Default to Dublin Core
?string $set = null,
?string $from = null,
?string $until = null
)
{
$classMetadataFactory = new ClassMetadataFactory(new AttributeLoader());
$metadataAwareNameConverter = new MetadataAwareNameConverter($classMetadataFactory);
$this->serializer = new Serializer(
[new ObjectNormalizer($classMetadataFactory, $metadataAwareNameConverter)],
[new XmlEncoder()]
);
$now = new \DateTimeImmutable;
$this->cursor = $cursor;
$this->size = $size;
// TODO Hard-coded 4 days added to now for validity
$this->validity = $now->add(new \DateInterval('P4D'));
$this->expirationDate = $this->validity->format('Y-m-d');
$this->metadataFormat = $metadataFormat;
$this->set = $set;
$this->from = $from;
$this->until = $until;
}
public function __toString(): string
{
return $this->encode();
}
public function toXML(): string
{
$normal = $this->serializer->normalize($this);
$normal['#'] = $this->__toString();
$token = $this->serializer->encode(
$normal,
'xml',
[
'xml_format_output' => true,
'xml_root_node_name' => 'resumptionToken',
// Don't include the XML declaration, handled by OaiBase
'encoder_ignored_node_types' => [
\XML_PI_NODE,
],
]
);
return $token;
}
/**
* @todo sets and formats from DB...
*/
public function encode(): string
{
$hexCurs = dechex($this->cursor);
$hexSize = dechex($this->size);
$hexTime = dechex($this->validity->getTimestamp());
$from = $this->from !== null ?
dechex((new DateTimeImmutable($this->from))->getTimestamp()) : 0;
$until = $this->until !== null ?
dechex((new DateTimeImmutable($this->until))->getTimestamp()) : 0;
$setMap = match ($this->set) {
null => 0,
'J:A' => 1,
'J:B' => 2,
'S:A' => 3,
'S:B' => 4,
'openaire' => 5,
'openaire_data' => 6,
'images' => 7,
'models' => 8,
default => 0
};
$format = match ($this->metadataFormat) {
'oai_dc' => 1,
'oai_datacite' => 2,
'oai_openaire' => 3,
};
return "{$hexCurs}-{$hexSize}-{$hexTime}-{$setMap}-$format-$from-$until";
}
/**
* @throws InvalidTokenException
*/
public static function decode(string $token): ResumptionToken
{
$parts = explode('-', $token);
$cursor = hexdec($parts[0]);
$size = hexdec($parts[1]);
$validity = hexdec($parts[2]);
$set = match ((int)$parts[3]) {
0 => null,
1 => 'J:A',
2 => 'J:B',
3 => 'S:A',
4 => 'S:B',
5 => 'openaire',
6 => 'openaire_data',
7 => 'images',
8 => 'models',
default => null
};
$format = match ((int)$parts[4]) {
1 => 'oai_dc',
2 => 'oai_datacite',
3 =>'oai_openaire',
default => 'oai_dc',
};
$from = hexdec($parts[5]);
$from = $from === 0 ?
null : (new DateTime)->setTimestamp($from)->format('Y-m-d');
$until = hexdec($parts[6]);
$until = $until === 0 ?
null : (new DateTime)->setTimestamp($until)->format('Y-m-d');
if (! ResumptionToken::validate($validity)) {
throw new InvalidTokenException;
}
return new static(
$cursor,
$size,
$format,
set: $set,
from: $from,
until: $until
);
}
private static function validate(int $timestamp): bool
{
$now = new \DateTimeImmutable;
$validity = \DateTimeImmutable::createFromFormat('U', (string)$timestamp);
return $validity->diff($now)->days >= 0;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\OAI\Verb;
use App\OAI\{
RecordOaiDc,
RecordOaiDataCite,
RecordOaiInterface
};
use App\OAI\Exception\IdentifierNotFoundException;
use App\OAI\Exception\InvalidFormatException;
use App\OAI\OaiBase;
use App\OAI\OaiError;
/**
* Metadata for a single OAI record
*/
final class GetRecord
{
public readonly RecordOaiInterface $record;
private ?OaiError $error = null;
private string $metadataFormat;
public function __construct(
string $identifier,
string $metadataFormat,
)
{
$this->metadataFormat = $metadataFormat;
$record = null;
try {
$record = match ($metadataFormat) {
'oai_dc' => new RecordOaiDc($identifier),
'oai_datacite' => new RecordOaiDataCite($identifier),
default => new RecordOaiDc($identifier),
};
}
catch (InvalidFormatException $e) {
$this->error = new OaiError($e->getCode(),$e->getMessage());
}
$this->record = $record;
}
/**
* Returns the XML response (GetRecord or OaiError)
*/
public function response(): string
{
if ($this->error) {
return $this->error->toXML();
}
try {
$xml = $this->record->toXML();
return OaiBase::create($xml, 'GetRecord', $this->metadataFormat);
}
catch (IdentifierNotFoundException $e) {
return (new OaiError($e->getCode(),$e->getMessage()))
->toXML();
}
}
}

29
src/OAI/Verb/Identify.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\OAI\Verb;
use App\OAI\OaiIdentify;
use App\OAI\OaiBase;
/**
* Identify data for the repository
*/
final class Identify
{
public readonly OaiIdentify $identify;
public function __construct()
{
$this->identify = new OaiIdentify;
}
/**
* Returns the XML response
*/
public function response(): string
{
$xml = $this->identify->toXML();
return OaiBase::create($xml, 'Identify');
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\OAI\Verb;
use App\OAI\ResumptionToken;
use App\OAI\Exception\InvalidTokenException;
use App\OAI\OaiBase;
use App\OAI\OaiError;
use AC\DB\Connection;
use \Symfony\Component\HttpFoundation\Request;
use function \Symfony\Component\String\b;
/**
* Represents a ListIdentifiers response
*/
final class ListIdentifiers
{
private Request $request;
private ?string $metadataFormat;
private ?ResumptionToken $resumptionToken;
public function __construct(Request $req)
{
$this->request = $req;
}
/**
* Returns the XML response
*/
public function response(): string
{
$this->metadataFormat = $this->request->get('metadataPrefix');
$until = $this->request->get('until');
$from = $this->request->get('from');
$set = $this->request->get('set');
$token = $this->request->get('resumptionToken');
if ($token !== null) {
$tokenObj = ResumptionToken::decode($token);
$set = $tokenObj->set;
$this->metadataFormat = $tokenObj->metadataFormat;
$from = $tokenObj->from;
$until = $tokenObj->until;
}
$hasErrors = $this->hasErrors($from, $until, $set);
if ($hasErrors !== false) return $hasErrors->toXML();
try {
$identifiers = $this->getIdentifiers(
$set,
$from,
$until,
);
}
catch (InvalidTokenException $e) {
return (new OaiError($e->getCode(), $e->getMessage()))
->toXML();
}
if (count($identifiers) === 0) {
return (new OaiError('noRecordsMatch', 'No records were found in repository.'))
->toXML();
}
$xml = '';
$xml = $this->createXML($identifiers);
$xml = b($xml)->toUnicodeString()->trim("\n");
$xml = $xml->prepend("<ListIdentifiers>\n");
$xml = $xml->append("\n</ListIdentifiers>");
return OaiBase::create($xml->toString(), 'ListIdentifiers', $this->metadataFormat);
}
private function checkGranularity(string $date): bool
{
$valid = true;
$pattern = "/\d{4}-\d{2}-\d{2}/";
preg_match($pattern, $date, $matches);
if (count($matches) === 0) return false;
$valid &= $matches[0] === $date;
$parts = explode('-', $date);
$valid &= checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0]);
return (bool)$valid;
}
/**
* This should check the given setspec against the available sets
* from the database
*/
private function checkValidSet(string $setspec): bool
{
// Implement
return false;
}
/**
* This should return the identifiers of records in the DB based on
* the given filters (set, from, until)
* @throws InvalidTokenException
* @return string[]
*/
private function getIdentifiers(?string $set, ?string $from, ?string $until,): array
{
// Implement
return ['first_identifier', 'second_identifier'];
}
private function createXML(array $identifiers): string
{
$xml = '';
foreach ($identifiers as $id) {
$xml .= "<header>\n";
$xml .= "<identifier>{$id['identifier']}</identifier>\n";
$xml .= "<datestamp>{$id['datestamp']}</datestamp>\n";
$xml .= "<setSpec>{$id['setSpec']}</setSpec>\n";
$xml .= "</header>";
}
if ($this->resumptionToken->cursor < $this->resumptionToken->size) {
$xml .= "\n{$this->resumptionToken->toXML()}";
}
return $xml;
}
private function hasErrors(?string $from, ?string $until, ?string $set): OaiError|false
{
$error = false;
if ($from !== null && ! $this->checkGranularity($from) ||
$until !== null && ! $this->checkGranularity($until)
) {
$error = new OaiError('badArgument', "The argument value for 'from' and/or 'until' is invalid.");
}
if (($until !== null && $from !== null) && ($from > $until)) {
$error = new OaiError('noRecordsMatch', 'No records were found in repository.');
}
if ($set !== null && ! $this->checkValidSet($set)) {
$error = new OaiError('badArgument', "The set '$set' does not exist in this repository.");
}
return $error;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\OAI\Verb;
use App\OAI\OaiBase;
use App\OAI\OaiError;
use AC\DB\Connection;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
/**
* Represents a ListMetadataFormats response
*/
final class ListMetadataFormats
{
public function __construct(
private Serializer $serializer = new Serializer(),
)
{
$this->serializer = new Serializer(
[new ObjectNormalizer()],
[new XmlEncoder()]
);
}
/**
* Returns the XML response
*/
public function response(): string
{
$formats = $this->getValidFormats();
$content = $this->serializer->encode(
['metadataFormat' => [...$formats]],
'xml',
[
'xml_format_output' => true,
'xml_root_node_name' => 'ListMetadataFormats',
// Don't include the XML declaration, handled by OaiBase
'encoder_ignored_node_types' => [
\XML_PI_NODE,
],
]
);
return OaiBase::create($content, 'ListMetadataFormats');
}
/**
* @return array<int,array<string,mixed>>
*/
private function getValidFormats(): array
{
$dbConn = Connection::new();
$queryBuilder = $dbConn->createQueryBuilder();
$formats = $queryBuilder->select(
'prefix as metadataPrefix',
'schemaLoc as schema',
'namespace as metadataNamespace',
)
->from('metadata_formats')
->fetchAllAssociative();
return $formats;
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\OAI\Verb;
use App\OAI\{
RecordOaiDc,
RecordOaiDataCite,
RecordOaiInterface,
ResumptionToken
};
use App\OAI\Exception\InvalidTokenException;
use App\OAI\OaiBase;
use App\OAI\OaiError;
use \Symfony\Component\HttpFoundation\Request;
use function \Symfony\Component\String\b;
/**
* Represents a ListRecords response
*/
final class ListRecords
{
private RecordOaiInterface $record;
private Request $request;
private ?string $metadataFormat;
private ?ResumptionToken $resumptionToken;
public function __construct(Request $req)
{
$this->request = $req;
}
/**
* Returns the XML response
* @todo Refactor (complexity...)
*/
public function response(): string
{
$this->metadataFormat = $this->request->get('metadataPrefix');
$until = $this->request->get('until');
$from = $this->request->get('from');
$set = $this->request->get('set');
$token = $this->request->get('resumptionToken');
if ($token !== null) {
$tokenObj = ResumptionToken::decode($token);
$set = $tokenObj->set;
$this->metadataFormat = $tokenObj->metadataFormat;
$from = $tokenObj->from;
$until = $tokenObj->until;
}
$hasErrors = $this->hasErrors($from, $until, $set);
if ($hasErrors !== false) return $hasErrors->toXML();
try {
$identifiers = $this->getIdentifiers(
$set,
$from,
$until,
);
}
catch (InvalidTokenException $e) {
return (new OaiError($e->getCode(), $e->getMessage()))
->toXML();
}
if (count($identifiers) === 0) {
return (new OaiError('noRecordsMatch', 'No records were found in repository.'))
->toXML();
}
$xml = '';
$xml = $this->createXML($identifiers, $this->metadataFormat);
$xml = b($xml)->toUnicodeString()->trim("\n");
$xml = $xml->prepend("<ListRecords>\n");
$xml = $xml->append("\n</ListRecords>");
return OaiBase::create($xml->toString(), 'ListRecords', $this->metadataFormat);
}
private function checkGranularity(string $date): bool
{
$valid = true;
$pattern = "/\d{4}-\d{2}-\d{2}/";
preg_match($pattern, $date, $matches);
if (count($matches) === 0) return false;
$valid &= $matches[0] === $date;
$parts = explode('-', $date);
$valid &= checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0]);
return (bool)$valid;
}
private function checkValidSet(string $setspec): bool
{
// Implement
return false;
}
/**
* @todo Implement limit for cumulative array
* @throws InvalidTokenException
* @return string[]
*/
private function getIdentifiers(?string $set, ?string $from, ?string $until,): array
{
$identifiers = [];
$decodedToken = null;
$offset = 0;
$size = 0;
$limit = (int)$_ENV['OAI_LIMIT'];
$token = $this->request->get('resumptionToken');
if ($token !== null) {
$decodedToken = ResumptionToken::decode($token);
$offset = $decodedToken->cursor;
}
if ($set === null && $this->metadataFormat === 'oai_dc') {
// Get resources and size (total number)
$this->resumptionToken = new ResumptionToken(
$offset + $limit,
$size,
set: $set,
metadataFormat: $this->metadataFormat,
from: $from,
until: $until,
);
return $identifiers;
}
return $identifiers;
}
private function createXML(
array $identifiers,
string $metadataFormat,
): string
{
$xml = '';
foreach ($identifiers as $id) {
$this->record = match ($metadataFormat) {
'oai_dc' => new RecordOaiDc($id['identifier'], verb: 'ListRecords'),
'oai_datacite' => new RecordOaiDataCite($id['identifier'], verb: 'ListRecords'),
default => new RecordOaiDc($id['identifier'], verb: 'ListRecords'),
};
$xml .= "\n" . $this->record->toXML();
}
if ($this->resumptionToken->cursor < $this->resumptionToken->size) {
$xml .= "\n{$this->resumptionToken->toXML()}";
}
return $xml;
}
private function hasErrors(?string $from, ?string $until, ?string $set): OaiError|false
{
$error = false;
if ($from !== null && ! $this->checkGranularity($from) ||
$until !== null && ! $this->checkGranularity($until)
) {
$error = new OaiError('badArgument', "The argument value for 'from' and/or 'until' is invalid.");
}
if (($until !== null && $from !== null) && ($from > $until)) {
$error = new OaiError('noRecordsMatch', 'No records were found in repository.');
}
if ($set !== null && ! $this->checkValidSet($set)) {
$error = new OaiError('badArgument', "The set '$set' does not exist in this repository.");
}
return $error;
}
}

57
src/OAI/Verb/ListSets.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\OAI\Verb;
use App\OAI\OaiBase;
use App\OAI\OaiError;
use AC\DB\Connection;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
/**
* Represents a ListSets response
*/
final class ListSets
{
public function __construct(
private Serializer $serializer = new Serializer(),
)
{
$this->serializer = new Serializer(
[new ObjectNormalizer()],
[new XmlEncoder()]
);
}
/**
* Returns the XML response
*/
public function response(): string
{
$sets = $this->getValidSets();
$content = $this->serializer->encode(
['set' => [...$sets]],
'xml',
[
'xml_format_output' => true,
'xml_root_node_name' => 'ListSets',
// Don't include the XML declaration, handled by OaiBase
'encoder_ignored_node_types' => [
\XML_PI_NODE,
],
]
);
return OaiBase::create($content, 'ListSets');
}
/**
* @return array<int,array<string,mixed>>
*/
private function getValidSets(): array
{
return [];
}
}