Initial upload (still WIP)

This commit is contained in:
Sebastian Meyer 2024-01-03 16:54:13 +01:00
parent 121cdf8010
commit 04583643e0
37 changed files with 8165 additions and 5 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true

4
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,4 @@
# These are supported funding model platforms
github: sebastian-meyer
custom: "https://paypal.me/sebastianmeyer"

16
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "weekly"
assignees:
- "sebastian-meyer"
labels: [ ]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
assignees:
- "sebastian-meyer"
labels: [ ]

40
.github/workflows/phpmd.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: PHP Mess Detector
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
permissions:
contents: read
jobs:
PHPMD:
name: PHPMD Scanner
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout Source Code
uses: actions/checkout@v4
- name: Setup Environment
uses: shivammathur/setup-php@v2
with:
php-version: "8.0"
coverage: none
tools: phpmd
- name: Run PHPMD
run: phpmd . sarif codesize --reportfile phpmd-results.sarif
continue-on-error: true
- name: Upload Analysis Results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: phpmd-results.sarif
wait-for-processing: true

35
.github/workflows/phpstan.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: PHP Static Analyzer
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
permissions:
contents: read
jobs:
PHPStan:
name: PHPStan Scanner
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Checkout Source Code
uses: actions/checkout@v4
- name: Setup Environment
uses: php-actions/composer@v6
with:
command: update
php_version: "8.0"
- name: Run PHPStan
uses: php-actions/phpstan@v3
with:
path: src/
configuration: phpstan.dist.neon

11
.gitignore vendored
View File

@ -1,6 +1,7 @@
composer.phar
/.vscode/
/config/config.yml
/data/
/var/
/vendor/
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
# composer.lock
.php-cs-fixer.php
phpstan.neon

38
.php-cs-fixer.dist.php Normal file
View File

@ -0,0 +1,38 @@
<?php
/**
* Useful PHP Basics
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace PhpCsFixer;
/**
* Configuration for PHP-CS-Fixer.
* @see https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/config.rst
*
* @return ConfigInterface
*/
return (new Config())
->setRiskyAllowed(true)
->setRules([
'@PSR12' => true,
])
->setFinder(
(new Finder())->in(__DIR__)
);

55
bin/cli Normal file
View File

@ -0,0 +1,55 @@
#!/usr/bin/env php
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2;
use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Doctrine\ORM\Tools\Console\EntityManagerProvider\SingleManagerProvider;
use Exception;
use OCC\OaiPmh2\Console\AddRecordCommand;
use OCC\OaiPmh2\Console\BulkUpdateCommand;
use OCC\OaiPmh2\Console\DeleteRecordCommand;
use OCC\OaiPmh2\Console\PruneResumptionTokensCommand;
use OCC\OaiPmh2\Console\UpdateFormatsCommand;
require __DIR__ . '/../vendor/autoload.php';
$commands = [
new AddRecordCommand(),
new BulkUpdateCommand(),
new DeleteRecordCommand(),
new PruneResumptionTokensCommand(),
new UpdateFormatsCommand()
];
try {
ConsoleRunner::run(
new SingleManagerProvider(
Database::getInstance()->getEntityManager()
),
$commands
);
} catch (Exception $exception) {
echo '[ERROR] Exception ' . $exception->getCode() . ' thrown:' . PHP_EOL;
echo $exception->getMessage() . PHP_EOL;
}

86
composer.json Normal file
View File

@ -0,0 +1,86 @@
{
"name": "opencultureconsulting/oai-pmh2",
"description": "This is a stand-alone OAI-PMH 2.0 data provider. It serves records in any XML metadata format from a database, supports deleted records, resumption tokens and sets.",
"type": "project",
"keywords": [
"oai",
"oaipmh",
"oaipmh2",
"oai-pmh",
"oai-pmh2",
"code4lib"
],
"homepage": "https://github.com/opencultureconsulting/oai-pmh2",
"readme": "README.md",
"license": ["GPL-3.0-or-later"],
"authors": [
{
"name": "Sebastian Meyer",
"email": "sebastian.meyer@opencultureconsulting.com",
"homepage": "https://www.opencultureconsulting.com",
"role": "maintainer"
}
],
"support": {
"issues": "https://github.com/opencultureconsulting/oai-pmh2/issues",
"source": "https://github.com/opencultureconsulting/oai-pmh2",
"docs": "https://github.com/opencultureconsulting/oai-pmh2/blob/main/README.md"
},
"require": {
"php": "^8.1",
"ext-dom": "*",
"ext-libxml": "*",
"ext-sqlite3": "*",
"doctrine/dbal": "^3.7",
"doctrine/orm": "^2.17",
"opencultureconsulting/basics": "^1.0",
"opencultureconsulting/psr15": "^1.0",
"symfony/cache": "^6.4",
"symfony/console": "^6.4",
"symfony/filesystem": "^6.4",
"symfony/serializer":"^6.4",
"symfony/validator": "^6.4",
"symfony/yaml": "^6.4"
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpstan/phpstan-strict-rules": "^1.5",
"friendsofphp/php-cs-fixer": "^3.45"
},
"autoload": {
"psr-4": {
"OCC\\OaiPmh2\\": "src/"
}
},
"scripts": {
"pre-install-cmd": [
"@php -r \"!is_dir('./data') && mkdir('./data', 0775);\"",
"@php -r \"!file_exists('./config/config.yml') && copy('./config/config.dist.yml', './config/config.yml');\""
],
"post-install-cmd": [
"@doctrine:clear-cache --quiet",
"@php bin/cli orm:generate-proxies --quiet"
],
"post-create-project-cmd": [
"@doctrine:initialize-database --quiet"
],
"doctrine:clear-cache": [
"@php bin/cli orm:clear-cache:metadata --flush",
"@php bin/cli orm:clear-cache:query --flush",
"@php bin/cli orm:clear-cache:result --flush"
],
"doctrine:initialize-database": [
"@php bin/cli orm:schema-tool:update --complete --force",
"@oai:update-formats --quiet"
],
"oai:update-formats": [
"@php bin/cli oai:formats:update"
]
},
"scripts-descriptions": {
"doctrine:clear-cache": "Clears the Doctrine/ORM metadata, query and result caches",
"doctrine:generate-proxies": "Generates the Doctrine/ORM entity proxies",
"doctrine:initialize-database": "Initializes a new database (NOT RECOMMENDED IN PRODUCTION!)",
"oai:update-formats": "Updates supported metadata formats from configuration"
}
}

4242
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

90
config/config.dist.yml Normal file
View File

@ -0,0 +1,90 @@
#
# The default configuration
#
# Copy this file as 'config.yml' and change the settings to your preferences.
# See https://www.openarchives.org/OAI/2.0/openarchivesprotocol.htm for further
# explanation.
#
#
# A human readable name for the repository
#
repositoryName: 'OAI-PMH 2.0 Data Provider'
#
# Email address for contacting the repository owner
#
# This has to be a valid email according to RFC 822 Address Specification.
# See https://www.w3.org/Protocols/rfc822/#z8 for further explanation.
#
adminEmail: admin@example.org
#
# Database connection details
#
# This has to be a valid data source name (DSN) URL. The scheme is used to
# specify a driver, the user and password in the URL encode user and password
# for the connection, followed by the host and port parts. The path after the
# authority part represents the name of the database (the leading slash is
# removed so add an extra slash to specify an absolute file path for SQLite).
# The placeholder "%BASEDIR%" may be used to represent the application's base
# directory.
# Any optional query parameters are used as additional connection parameters.
# Since the scheme determines the database driver, it also specifies if the PDO
# abstraction ("mariadb", "mssql", "mysql", "oracle", "postgres", "sqlite") or
# native drivers ("ibm-db2", "mysqli", "oci8", "pgsql", "sqlite3", "sqlsrv")
# should be used to handle the connection. Make sure the corresponding PHP
# extensions are installed.
# See https://www.doctrine-project.org/projects/doctrine-dbal/en/3.7/reference/configuration.html#connecting-using-a-url
# for further explanation.
#
# %DRIVER%://[%USER%[:%PASSWORD%]@]%HOST%[:%PORT%]/%DBNAME%[?%OPTIONS%]
#
# Examples:
# database: 'mssql://oaipmh:secret@127.0.0.1/oaipmh'
# database: 'mysql://root@localhost/oai?charset=utf8mb4'
# database: 'pgsql://oaipmh:secret@localhost:5432/oai_data_provider'
# database: 'sqlite3:////home/oaipmh/database.db'
#
# Run "composer doctrine:initialize-database" after switching to a new DB to
# test the settings and initialize the database!
#
database: 'sqlite3:///%BASEDIR%/data/sqlite3.db'
#
# Metadata formats, namespaces and schemas of your records
#
# The default is 'oai_dc' which is also required by the OAI-PMH specification,
# but technically you can provide any XML based data formats you want. Just add
# another entry with the metadata prefix as key and namespace/schema URIs as
# array values or replace the default entry (although not recommended).
# You do not have to provide every record in each metadata format, but if you
# have the same record in multiple formats, it's highly recommended to use the
# same identifier for all versions of the record.
#
# Run "composer oai:update-formats" after changing metadata prefixes to update
# the database accordingly!
#
metadataPrefix: {
oai_dc: {
namespace: 'http://www.openarchives.org/OAI/2.0/oai_dc/',
schema: 'https://www.openarchives.org/OAI/2.0/oai_dc.xsd'
}
}
#
# Maximum number of records to return per request
#
# For larger result sets resumption tokens are provided repeatedly, which
# allow requesting more batches of records until the set is complete.
#
# [1 - 100]
#
maxRecords: 50
#
# Number of seconds a resumption token should be valid
#
# [300 - 86400]
#
tokenValid: 1800 # 30 minutes

14
phpstan.dist.neon Normal file
View File

@ -0,0 +1,14 @@
#
# Configuration for PHPStan.
# @see https://phpstan.org/config-reference
#
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
parameters:
level: 9
strictRules:
noVariableVariables: false
paths:
- src

28
public/index.php Normal file
View File

@ -0,0 +1,28 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2;
require __DIR__ . '/../vendor/autoload.php';
$app = new App();
$app->run();

View File

@ -0,0 +1,520 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2006 Christopher Gutteridge <cjg@ecs.soton.ac.uk>
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:oai="http://www.openarchives.org/OAI/2.0/">
<xsl:output method="html" />
<xsl:template name="style">
* {
font-family: "Lucida Sans Unicode", sans-serif;
}
body {
margin: 1em 2em 1em 2em;
}
h1,
h2,
h3,
h4 {
clear: left;
}
h1 {
padding-bottom: 4px;
margin-bottom: 0px;
}
h2 {
margin-bottom: 0.5em;
}
h3 {
margin-bottom: 0.3em;
font-size: medium;
}
h4 {
margin-bottom: 0.2em;
font-size: small;
}
table {
margin-top: 10px;
}
td.key {
background-color: #e0e0ff;
padding: 3px;
text-align: right;
border: 1px solid #c0c0c0;
white-space: nowrap;
vertical-align: top;
}
td.value {
vertical-align: center;
padding-left: 1em;
padding: 3px;
}
.link {
border: 1px outset #88f;
background-color: #c0c0ff;
padding: 1px 4px;
font-size: 80%;
text-decoration: none;
color: black;
}
.link:hover {
color: gray;
}
.link:active {
color: red;
border: 1px inset #88f;
background-color: #a0a0df;
}
.results {
margin-bottom: 1.5em;
}
div.quicklinks {
border-bottom: 2px solid #ccc;
border-top: 2px solid #ccc;
}
ul {
margin: 2px 0;
padding: 4px;
text-align: left;
clear: left;
}
ul li {
font-size: 80%;
display: inline;
list-style: none;
}
ol {
padding: 0;
}
ol>li {
list-style: none;
padding: 0 5px 5px;
margin: 0 0 1em;
border: 1px solid #c0c0c0;
}
p {
margin: 0;
padding: 5px;
}
p.info {
font-size: 80%;
}
.xmlSource {
font-size: 70%;
border: solid #c0c0a0 1px;
background-color: #ffffe0;
padding: 2em 2em 2em 0;
}
.xmlBlock {
padding-left: 2em;
}
.xmlTagName {
color: #800000;
font-weight: bold;
}
.xmlAttrName {
font-weight: bold;
}
.xmlAttrValue {
color: #0000c0;
}
</xsl:template>
<xsl:variable name='verb' select="/oai:OAI-PMH/oai:request/@verb"/>
<xsl:variable name='metadataPrefix'>
<xsl:choose>
<xsl:when test="/oai:OAI-PMH/oai:request/@metadataPrefix != ''">
<xsl:value-of select="/oai:OAI-PMH/oai:request/@metadataPrefix"/>
</xsl:when>
<xsl:when test="/oai:OAI-PMH/oai:request/@resumptionToken != ''">
<xsl:value-of select="substring-after(/oai:OAI-PMH/oai:request/@resumptionToken,'_')"/>
</xsl:when>
</xsl:choose>
</xsl:variable>
<xsl:variable name='identifier' select="/oai:OAI-PMH/oai:request/@identifier"/>
<xsl:variable name='from' select="/oai:OAI-PMH/oai:request/@from"/>
<xsl:variable name='until' select="/oai:OAI-PMH/oai:request/@until"/>
<xsl:variable name='set' select="/oai:OAI-PMH/oai:request/@set"/>
<xsl:variable name='resumptionToken' select="/oai:OAI-PMH/oai:request/@resumptionToken"/>
<xsl:template match="/">
<html>
<head>
<title>OAI-PMH 2.0 Request Results</title>
<style><xsl:call-template name="style"/></style>
</head>
<body>
<h1>OAI-PMH 2.0 Request Results</h1>
<xsl:call-template name="quicklinks"/>
<xsl:apply-templates select="/oai:OAI-PMH"/>
<xsl:call-template name="quicklinks"/>
<p class="info">You are viewing an HTML version of the XML OAI-PMH response. To see the underlying XML as it appears to any OAI-PMH harvester use your web browser's <em>view source</em> option or disable XSLT processing.</p>
<p class="info">This XSL script was originally written by Christopher Gutteridge at <a href="https://www.southampton.ac.uk/">University of Southampton</a> for the <a href="https://www.eprints.org/">EPrints</a> project and was later adapted by Sebastian Meyer at <a href="https://www.opencultureconsulting.com/">Open Culture Consulting</a> to be more generally applicable to other OAI-PMH interfaces. It is available on <a href="https://github.com/opencultureconsulting/oai-pmh2">GitHub</a> for free!</p>
</body>
</html>
</xsl:template>
<xsl:template name="quicklinks">
<div class="quicklinks">
<ul>
<li>&#187; <a class="link" href="?verb=Identify">Identify</a></li>
<li>&#187; <a class="link" href="?verb=ListMetadataFormats">ListMetadataFormats</a></li>
<xsl:if test="$identifier">
<li>&#187; <a class="link" href="?verb=ListMetadataFormats&amp;identifier={$identifier}">ListMetadataFormats (for record <em><xsl:value-of select="$identifier"/></em>)</a></li>
</xsl:if>
<xsl:if test="$metadataPrefix != ''">
<li>&#187; <a class="link" href="?verb=ListIdentifiers&amp;metadataPrefix={$metadataPrefix}">ListIdentifiers (for format <em><xsl:value-of select="$metadataPrefix"/></em>)</a></li>
<li>&#187; <a class="link" href="?verb=ListRecords&amp;metadataPrefix={$metadataPrefix}">ListRecords (for format <em><xsl:value-of select="$metadataPrefix"/></em>)</a></li>
<xsl:if test="$identifier">
<li>&#187; <a class="link" href="?verb=GetRecord&amp;metadataPrefix={$metadataPrefix}&amp;identifier={$identifier}">GetRecord (<em><xsl:value-of select="$identifier"/></em> in <em><xsl:value-of select="$metadataPrefix"/></em>)</a></li>
</xsl:if>
</xsl:if>
<li>&#187; <a class="link" href="?verb=ListSets">ListSets</a></li>
<xsl:if test="//oai:resumptionToken">
<li>&#187; <a class="link" href="?verb={$verb}&amp;resumptionToken={//oai:resumptionToken}">Resume</a></li>
</xsl:if>
</ul>
</div>
</xsl:template>
<xsl:template match="/oai:OAI-PMH">
<table class="values">
<tr><td class="key">Datestamp of Response</td>
<td class="value"><xsl:value-of select="oai:responseDate"/></td></tr>
<tr><td class="key">Request URL</td>
<td class="value"><xsl:value-of select="oai:request"/></td></tr>
<tr><td class="key">Request Parameters</td>
<td class="value">
<xsl:if test="oai:request/@verb">verb = <em><xsl:value-of select="$verb"/></em><br/></xsl:if>
<xsl:if test="oai:request/@metadataPrefix">metadataPrefix = <em><xsl:value-of select="$metadataPrefix"/></em><br/></xsl:if>
<xsl:if test="oai:request/@identifier">identifier = <em><xsl:value-of select="$identifier"/></em><br/></xsl:if>
<xsl:if test="oai:request/@from">from = <em><xsl:value-of select="$from"/></em><br/></xsl:if>
<xsl:if test="oai:request/@until">until = <em><xsl:value-of select="$until"/></em><br/></xsl:if>
<xsl:if test="oai:request/@set">set = <em><xsl:value-of select="$set"/></em><br/></xsl:if>
<xsl:if test="oai:request/@resumptionToken">resumptionToken = <em><xsl:value-of select="$resumptionToken"/></em><br/></xsl:if>
</td></tr>
</table>
<xsl:choose>
<xsl:when test="oai:error">
<h2>Error</h2>
<p>The request could not be completed due to the following error.</p>
<div class="results">
<xsl:apply-templates select="oai:error"/>
</div>
</xsl:when>
<xsl:otherwise>
<h2><xsl:value-of select="$verb"/></h2>
<p>The request was completed with the following results.</p>
<div class="results">
<xsl:apply-templates select="oai:Identify" />
<xsl:apply-templates select="oai:ListMetadataFormats"/>
<xsl:apply-templates select="oai:ListIdentifiers"/>
<xsl:apply-templates select="oai:ListRecords"/>
<xsl:apply-templates select="oai:GetRecord"/>
</div>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!--
Error
-->
<xsl:template match="oai:error">
<table class="values">
<tr><td class="key">Error Code</td>
<td class="value"><xsl:value-of select="@code"/></td></tr>
</table>
<p class="error"><xsl:value-of select="."/></p>
</xsl:template>
<!--
Identify
-->
<xsl:template match="oai:Identify">
<ol>
<li>
<h3>Repository Identification</h3>
<table class="values">
<tr><td class="key">Name</td>
<td class="value"><xsl:value-of select="oai:repositoryName"/></td></tr>
<tr><td class="key">Base URL</td>
<td class="value"><a href="{oai:baseURL}"><xsl:value-of select="oai:baseURL"/></a></td></tr>
<tr><td class="key">Protocol Version</td>
<td class="value"><xsl:value-of select="oai:protocolVersion"/></td></tr>
<tr><td class="key">Earliest Datestamp</td>
<td class="value"><xsl:value-of select="oai:earliestDatestamp"/></td></tr>
<tr><td class="key">Deleted Record Policy</td>
<td class="value"><xsl:value-of select="oai:deletedRecord"/></td></tr>
<tr><td class="key">Granularity</td>
<td class="value"><xsl:value-of select="oai:granularity"/></td></tr>
<tr><td class="key">Administrative Email</td>
<td class="value"><a href="mailto:{oai:adminEmail}"><xsl:value-of select="oai:adminEmail"/></a></td></tr>
</table>
</li>
</ol>
</xsl:template>
<!--
ListMetadataFormats
-->
<xsl:template match="oai:ListMetadataFormats">
<xsl:choose>
<xsl:when test="$identifier">
<p class="info">This is a list of metadata formats available for the record <em><xsl:value-of select="$identifier"/></em>.</p>
</xsl:when>
<xsl:otherwise>
<p class="info">This is a list of metadata formats available from this repository.</p>
</xsl:otherwise>
</xsl:choose>
<ol>
<xsl:apply-templates select="oai:metadataFormat"/>
</ol>
</xsl:template>
<!--
Metadata Format Details
-->
<xsl:template match="oai:metadataFormat">
<li>
<h3>Metadata Format <em><xsl:value-of select="oai:metadataPrefix"/></em></h3>
<ul>
<li>&#187; <a class="link" href="?verb=ListIdentifiers&amp;metadataPrefix={oai:metadataPrefix}">ListIdentifiers</a></li>
<li>&#187; <a class="link" href="?verb=ListRecords&amp;metadataPrefix={oai:metadataPrefix}">ListRecords</a></li>
<xsl:if test="$identifier"><li>&#187; <a class="link" href="?verb=GetRecord&amp;metadataPrefix={oai:metadataPrefix}&amp;identifier={$identifier}">GetRecord</a></li></xsl:if>
</ul>
<table class="values">
<tr><td class="key">Prefix</td>
<td class="value"><xsl:value-of select="oai:metadataPrefix"/></td></tr>
<tr><td class="key">Namespace</td>
<td class="value"><xsl:value-of select="oai:metadataNamespace"/></td></tr>
<tr><td class="key">Schema</td>
<td class="value"><a href="{oai:schema}"><xsl:value-of select="oai:schema"/></a></td></tr>
</table>
</li>
</xsl:template>
<!--
ListIdentifiers
-->
<xsl:template match="oai:ListIdentifiers">
<p class="info">This is a list of records' identifiers available for the metadata format <em><xsl:value-of select="$metadataPrefix"/></em>.</p>
<ol>
<xsl:apply-templates select="oai:header" />
</ol>
<xsl:apply-templates select="oai:resumptionToken" />
</xsl:template>
<xsl:template match="oai:ListIdentifiers/oai:header">
<li>
<h3>Record Header <em><xsl:value-of select="oai:identifier"/></em></h3>
<ul>
<li>&#187; <a class="link" href="?verb=ListMetadataFormats&amp;identifier={oai:identifier}">ListMetadataFormats</a></li>
<li>&#187; <a class="link" href="?verb=GetRecord&amp;metadataPrefix={$metadataPrefix}&amp;identifier={oai:identifier}">GetRecord</a></li>
</ul>
<table class="values">
<tr><td class="key">Identifier</td>
<td class="value"><xsl:value-of select="oai:identifier"/></td></tr>
<tr><td class="key">Datestamp</td>
<td class="value"><xsl:value-of select="oai:datestamp"/></td></tr>
<tr><td class="key">Deleted</td>
<td class="value">
<xsl:choose>
<xsl:when test="@status = 'deleted'">yes</xsl:when>
<xsl:otherwise>no</xsl:otherwise>
</xsl:choose>
</td></tr>
</table>
</li>
</xsl:template>
<!--
ListRecords
-->
<xsl:template match="oai:ListRecords">
<p class="info">This is a list of records available for the metadata format <em><xsl:value-of select="$metadataPrefix"/></em>.</p>
<ol>
<xsl:apply-templates select="oai:record" />
</ol>
<xsl:apply-templates select="oai:resumptionToken" />
</xsl:template>
<!--
GetRecord
-->
<xsl:template match="oai:GetRecord">
<p class="info">This is the record <em><xsl:value-of select="$identifier"/></em> in the metadata format <em><xsl:value-of select="$metadataPrefix"/></em>.</p>
<ol>
<xsl:apply-templates select="oai:record" />
</ol>
</xsl:template>
<!--
Record Details
-->
<xsl:template match="oai:record">
<li>
<xsl:apply-templates select="oai:header" />
<xsl:apply-templates select="oai:metadata" />
</li>
</xsl:template>
<xsl:template match="oai:record/oai:header">
<h3>Record <em><xsl:value-of select="oai:identifier"/></em></h3>
<ul>
<li>&#187; <a class="link" href="?verb=ListMetadataFormats&amp;identifier={oai:identifier}">ListMetadataFormats</a></li>
<xsl:if test="$verb != 'GetRecord'"><li>&#187; <a class="link" href="?verb=GetRecord&amp;metadataPrefix={$metadataPrefix}&amp;identifier={oai:identifier}">GetRecord</a></li></xsl:if>
</ul>
<table class="values">
<tr><td class="key">Identifier</td>
<td class="value"><xsl:value-of select="oai:identifier"/></td></tr>
<tr><td class="key">Datestamp</td>
<td class="value"><xsl:value-of select="oai:datestamp"/></td></tr>
</table>
<xsl:if test="@status = 'deleted'"><h4>This record has been deleted.</h4></xsl:if>
</xsl:template>
<xsl:template match="oai:metadata">
<xsl:apply-templates select="*" />
</xsl:template>
<!--
Resumption Token
-->
<xsl:template match="oai:resumptionToken">
<p>There are more results.</p>
<ul>
<li>&#187; <a class="link" href="?verb={$verb}&amp;resumptionToken={.}">Resume</a></li>
</ul>
<table class="values">
<tr><td class="key">Cursor Position</td>
<td class="value"><xsl:value-of select="@cursor"/></td></tr>
<tr><td class="key">Total Records</td>
<td class="value"><xsl:value-of select="@completeListSize"/></td></tr>
<tr><td class="key">Expiration Datestamp</td>
<td class="value"><xsl:value-of select="@expirationDate"/></td></tr>
<tr><td class="key">Resumption Token</td>
<td class="value"><xsl:value-of select="."/></td></tr>
</table>
</xsl:template>
<!--
Unknown Metadata
-->
<xsl:template match="oai:metadata/*" priority='-100'>
<h4>Metadata Format <em><xsl:value-of select="$metadataPrefix"/></em></h4>
<div class="xmlSource">
<xsl:apply-templates select="." mode='xmlMarkup' />
</div>
</xsl:template>
<!--
DublinCore Metadata
-->
<xsl:template match="oai_dc:dc" xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/">
<h4>Metadata Format <em>DublinCore</em></h4>
<table>
<xsl:apply-templates select="*" />
</table>
</xsl:template>
<xsl:template match="dc:title" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Title</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:creator" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Author or Creator</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:subject" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Subject and Keywords</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:description" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Description</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:publisher" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Publisher</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:contributor" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Other Contributor</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:date" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Date</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:type" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Resource Type</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:format" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Format</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:identifier" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Resource Identifier</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:source" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Source</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:language" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Language</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:relation" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Relation</td><td class="value">
<xsl:choose>
<xsl:when test='starts-with(.,"http")'>
<a href="{.}"><xsl:value-of select="."/></a>
</xsl:when>
<xsl:otherwise>
<xsl:value-of select="."/>
</xsl:otherwise>
</xsl:choose>
</td></tr>
</xsl:template>
<xsl:template match="dc:coverage" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Coverage</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<xsl:template match="dc:rights" xmlns:dc="http://purl.org/dc/elements/1.1/">
<tr><td class="key">Rights Management</td><td class="value"><xsl:value-of select="."/></td></tr>
</xsl:template>
<!--
XML Pretty Maker
-->
<xsl:template match="node()" mode='xmlMarkup'>
<div class="xmlBlock">
&lt;<span class="xmlTagName"><xsl:value-of select='name(.)' /></span><xsl:apply-templates select="@*" mode='xmlMarkup'/>&gt;<xsl:apply-templates select="node()" mode='xmlMarkup' />&lt;/<span class="xmlTagName"><xsl:value-of select='name(.)' /></span>&gt;
</div>
</xsl:template>
<xsl:template match="text()" mode='xmlMarkup'><span class="xmlText"><xsl:value-of select='.' /></span></xsl:template>
<xsl:template match="@*" mode='xmlMarkup'>
<xsl:text> </xsl:text><span class="xmlAttrName"><xsl:value-of select='name()' /></span>="<span class="xmlAttrValue"><xsl:value-of select='.' /></span>"
</xsl:template>
</xsl:stylesheet>

59
src/App.php Normal file
View File

@ -0,0 +1,59 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2;
use OCC\OaiPmh2\Middleware\Dispatcher;
use OCC\PSR15\QueueRequestHandler;
/**
* Main application of the OAI-PMH 2.0 Data Provider.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
class App
{
/**
* The PSR-15 Server Request Handler.
*/
protected QueueRequestHandler $requestHandler;
/**
* Instantiate application.
*/
public function __construct()
{
$this->requestHandler = new QueueRequestHandler([new Dispatcher()]);
}
/**
* Run the application.
*
* @return void
*/
public function run(): void
{
$this->requestHandler->handle();
$this->requestHandler->respond();
}
}

178
src/Configuration.php Normal file
View File

@ -0,0 +1,178 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2;
use OCC\Basics\Traits\Singleton;
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Yaml\Yaml;
/**
* Reads, validates and provides configuration settings.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*
* @property-read string $repositoryName
* @property-read string $adminEmail
* @property-read string $database
* @property-read array $metadataPrefix
* @property-read int $maxRecords
* @property-read int $tokenValid
*
* @template TKey of string
* @template TValue
*/
class Configuration
{
use Singleton;
/**
* Fully qualified path to the configuration file.
*
* @var string
*/
protected const CONFIG_FILE = __DIR__ . '/../config/config.yml';
/**
* The configuration settings.
*
* @var array<TKey, TValue>
*/
protected readonly array $settings;
/**
* Get constraints for configuration array.
*
* @return Assert\Collection The collection of constraints
*/
protected function getValidationConstraints(): Assert\Collection
{
return new Assert\Collection([
'repositoryName' => [
new Assert\Type('string'),
new Assert\NotBlank()
],
'adminEmail' => [
new Assert\Type('string'),
new Assert\Email(['mode' => 'html5']),
new Assert\NotBlank()
],
'database' => [
new Assert\Type('string'),
new Assert\NotBlank()
],
'metadataPrefix' => [
new Assert\Type('array'),
new Assert\All([
new Assert\Collection([
'schema' => [
new Assert\Type('string'),
new Assert\Url(),
new Assert\NotBlank()
],
'namespace' => [
new Assert\Type('string'),
new Assert\Url(),
new Assert\NotBlank()
]
])
])
],
'maxRecords' => [
new Assert\Type('int'),
new Assert\Range([
'min' => 1,
'max' => 100
])
],
'tokenValid' => [
new Assert\Type('int'),
new Assert\Range([
'min' => 300,
'max' => 86400
])
]
]);
}
/**
* Read and validate configuration file.
*
* @return array<TKey, TValue> The configuration array
*
* @throws FileNotFoundException|ValidationFailedException
*/
protected function loadConfigFile(): array
{
$configPath = Path::canonicalize(self::CONFIG_FILE);
if (!is_readable($configPath)) {
throw new FileNotFoundException(
sprintf(
'Configuration file "%s" not found or not readable.',
$configPath
),
500,
null,
$configPath
);
}
$config = Yaml::parseFile($configPath);
$validator = Validation::createValidator();
$violations = $validator->validate($config, $this->getValidationConstraints());
if ($violations->count() > 0) {
throw new ValidationFailedException(null, $violations);
}
/** @var array<TKey, TValue> $config */
return $config;
}
/**
* Load and validate configuration settings from YAML file.
*
* @throws FileNotFoundException|ValidationFailedException
*/
private function __construct()
{
try {
$this->settings = $this->loadConfigFile();
} catch (FileNotFoundException|ValidationFailedException $exception) {
throw $exception;
}
}
/**
* Magic getter for $this->settings.
*
* @param TKey $name The setting to retrieve
*
* @return TValue|null The setting or NULL
*/
public function __get(string $name): mixed
{
return $this->settings[$name] ?? null;
}
}

View File

@ -0,0 +1,46 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Console;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Add or update a record in the database.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
#[AsCommand(
name: 'oai:records:add',
description: 'Add or update a record in the database'
)]
class AddRecordCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,48 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Console;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Update records in database from CSV file.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
#[AsCommand(
name: 'oai:records:bulk-update',
description: 'Update records in database from CSV file'
)]
class BulkUpdateCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
// https://symfony.com/doc/current/console/input.html
// https://symfony.com/doc/current/components/serializer.html#the-csvencoder
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,46 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Console;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Delete a record from database.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
#[AsCommand(
name: 'oai:records:delete',
description: 'Delete a record from database'
)]
class DeleteRecordCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,56 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Database;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Prune expired resumption tokens from database.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
#[AsCommand(
name: 'oai:tokens:prune',
description: 'Prune expired resumption tokens from database'
)]
class PruneResumptionTokensCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$expired = Database::getInstance()->pruneResumptionTokens();
$output->writeln([
'',
sprintf(
' [OK] %d resumption tokens are expired and were successfully deleted. ',
$expired
),
''
]);
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,135 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Configuration;
use OCC\OaiPmh2\Database;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Synchronize metadata formats in database with configuration.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
#[AsCommand(
name: 'oai:formats:update',
description: 'Update metadata formats in database from configuration'
)]
class UpdateFormatsCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$formats = Configuration::getInstance()->metadataPrefix;
$inDatabase = Database::getInstance()->getMetadataFormats()->getQueryResult();
$added = 0;
$deleted = 0;
foreach ($formats as $prefix => $format) {
if (in_array($prefix, array_keys($inDatabase), true)) {
if (
$format['namespace'] === $inDatabase[$prefix]->getNamespace()
and $format['schema'] === $inDatabase[$prefix]->getSchema()
) {
continue;
}
}
if (Database::getInstance()->addOrUpdateMetadataFormat($prefix, $format['namespace'], $format['schema'])) {
++$added;
$output->writeln([
sprintf(
' [OK] Metadata format "%s" added or updated successfully. ',
$prefix
)
]);
} else {
$output->writeln([
sprintf(
' [ERROR] Could not add or update metadata format "%s". ',
$prefix
)
]);
}
}
foreach (array_keys($inDatabase) as $prefix) {
if (!in_array($prefix, array_keys($formats), true)) {
if (Database::getInstance()->removeMetadataFormat($prefix)) {
++$deleted;
$output->writeln([
sprintf(
' [OK] Metadata format "%s" and all associated records deleted successfully. ',
$prefix
)
]);
} else {
$output->writeln([
sprintf(
' [ERROR] Could not delete metadata format "%s". ',
$prefix
)
]);
}
}
}
/** @var Application */
$app = $this->getApplication();
$app->doRun(
new ArrayInput([
'command' => 'orm:clear-cache:result',
'--flush' => true
]),
new NullOutput()
);
$currentFormats = array_keys(Database::getInstance()->getMetadataFormats()->getQueryResult());
if (count($currentFormats) > 0) {
$output->writeln(
[
'',
' The following metadata formats are currently supported: ',
' ======================================================= ',
'',
' "' . implode('", "', $currentFormats) . '" ',
''
],
1 | 16
);
} else {
$output->writeln(
[
'',
' [INFO] There are currently no metadata formats supported. ',
' Please add a metadata prefix to config/config.yml and run ',
' command "php bin/cli oai:formats:update" again! ',
''
],
1 | 16
);
}
return Command::SUCCESS;
}
}

441
src/Database.php Normal file
View File

@ -0,0 +1,441 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2;
use DateTime;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Schema\AbstractAsset;
use Doctrine\DBAL\Tools\DsnParser;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Configuration as DoctrineConfiguration;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
use Doctrine\ORM\Proxy\ProxyFactory;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Exception;
use OCC\Basics\Traits\Singleton;
use OCC\OaiPmh2\Database\Format;
use OCC\OaiPmh2\Database\Record;
use OCC\OaiPmh2\Database\Result;
use OCC\OaiPmh2\Database\Set;
use OCC\OaiPmh2\Database\Token;
use Symfony\Component\Cache\Adapter\PhpFilesAdapter;
use Symfony\Component\Filesystem\Path;
/**
* Handles all database shenanigans.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*
* @template Formats of array<string, Format>
* @template Records of array<string, Record>
* @template Sets of array<string, Set>
*/
class Database
{
use Singleton;
protected const DB_TABLES = [
'formats',
'records',
'records_sets',
'sets',
'tokens'
];
/**
* This holds the Doctrine entity manager.
*/
protected EntityManager $entityManager;
/**
* Add or update metadata format.
*
* @param string $prefix The metadata prefix
* @param string $namespace The namespace URI
* @param string $schema The schema URL
*
* @return bool Whether the format was inserted/updated successfully
*/
public function addOrUpdateMetadataFormat(string $prefix, string $namespace, string $schema): bool
{
$inDatabase = $this->getMetadataFormats()->getQueryResult();
if (in_array($prefix, array_keys($inDatabase), true)) {
try {
$dql = $this->entityManager->createQueryBuilder();
$dql->update(Format::class, 'format')
->set('format.namespace', ':namepsace')
->set('format.xmlSchema', ':schema')
->where($dql->expr()->eq('format.prefix', ':prefix'))
->setParameter('prefix', $prefix)
->setParameter('namespace', $namespace)
->setParameter('schema', $schema);
$query = $dql->getQuery();
$query->execute();
return true;
} catch (Exception) {
return false;
}
} else {
try {
$format = new Format($prefix, $namespace, $schema);
$this->entityManager->persist($format);
$this->entityManager->flush();
return true;
} catch (Exception) {
return false;
}
}
}
/**
* Get the earliest datestamp of any record.
*
* @return string The earliest datestamp
*/
public function getEarliestDatestamp(): string
{
$timestamp = '0000-00-00T00:00:00Z';
$dql = $this->entityManager->createQueryBuilder();
$dql->select('record')
->from(Record::class, 'record')
->orderBy('record.lastChanged', 'ASC')
->setMaxResults(1);
$query = $dql->getQuery();
$query->enableResultCache();
/** @var ?array<string, \DateTime> */
$result = $query->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY);
if (isset($result)) {
$timestamp = $result['lastChanged']->format('Y-m-d\TH:i:s\Z');
}
return $timestamp;
}
/**
* Get the Doctrine entity manager.
*
* @return EntityManager The entity manager instance
*/
public function getEntityManager(): EntityManager
{
return $this->entityManager;
}
/**
* Get all metadata prefixes.
*
* @param ?string $identifier Optional record identifier
*
* @return Result<Formats> The metadata prefixes
*/
public function getMetadataFormats(?string $identifier = null): Result
{
$dql = $this->entityManager->createQueryBuilder();
$dql->select('format')
->from(Format::class, 'format', 'format.prefix');
if (isset($identifier)) {
$dql->innerJoin(
'format.records',
'records',
'WITH',
$dql->expr()->andX(
$dql->expr()->eq('records.identifier', ':identifier'),
$dql->expr()->neq('records.data', '')
)
)
->setParameter('identifier', $identifier);
}
$query = $dql->getQuery();
$query->enableResultCache();
/** @var Formats */
$queryResult = $query->getResult();
return new Result($queryResult);
}
/**
* Get a single record.
*
* @param string $identifier The record identifier
* @param string $metadataPrefix The metadata prefix
*
* @return ?Record The record or NULL on failure
*/
public function getRecord(string $identifier, string $metadataPrefix): ?Record
{
$dql = $this->entityManager->createQueryBuilder();
$dql->select('record')
->from(Record::class, 'record')
->where($dql->expr()->eq('record.identifier', ':identifier'))
->andWhere($dql->expr()->eq('record.format', ':format'))
->setParameter('identifier', $identifier)
->setParameter('format', $metadataPrefix)
->setMaxResults(1);
$query = $dql->getQuery();
/** @var ?Record */
return $query->getOneOrNullResult();
}
/**
* Get list of records.
*
* @param string $verb The currently requested verb ('ListIdentifiers' or 'ListRecords')
* @param string $metadataPrefix The metadata prefix
* @param int $counter Counter for split result sets
* @param ?string $from The "from" datestamp
* @param ?string $until The "until" datestamp
* @param ?string $set The set spec
*
* @return Result<Records> The records and possibly a resumtion token
*/
public function getRecords(
string $verb,
string $metadataPrefix,
int $counter = 0,
?string $from = null,
?string $until = null,
?string $set = null
): Result
{
$maxRecords = Configuration::getInstance()->maxRecords;
$cursor = $counter * $maxRecords;
$dql = $this->entityManager->createQueryBuilder();
$dql->select('record')
->from(Record::class, 'record', 'record.identifier')
->where($dql->expr()->eq('record.format', ':metadataPrefix'))
->setParameter('metadataPrefix', $metadataPrefix)
->setFirstResult($cursor)
->setMaxResults($maxRecords);
if (isset($from)) {
$dql->andWhere($dql->expr()->gte('record.lastChanged', ':from'));
$dql->setParameter('from', new DateTime($from));
}
if (isset($until)) {
$dql->andWhere($dql->expr()->lte('record.lastChanged', ':until'));
$dql->setParameter('until', new DateTime($until));
}
if (isset($set)) {
$dql->andWhere($dql->expr()->in('record.sets', ':set'));
$dql->setParameter('set', $set);
}
$query = $dql->getQuery();
/** @var Records */
$queryResult = $query->getResult();
$result = new Result($queryResult);
$paginator = new Paginator($query, true);
if (count($paginator) > ($cursor + count($result))) {
$token = new Token($verb, [
'counter' => $counter + 1,
'completeListSize' => count($paginator),
'metadataPrefix' => $metadataPrefix,
'from' => $from,
'until' => $until,
'set' => $set
]);
$this->entityManager->persist($token);
$this->entityManager->flush();
$result->setResumptionToken($token);
}
return $result;
}
/**
* Get resumption token.
*
* @param string $token The token
* @param string $verb The current verb to validate token
*
* @return ?Token The resumption token or NULL if invalid
*/
public function getResumptionToken(string $token, string $verb): ?Token
{
$dql = $this->entityManager->createQueryBuilder();
$dql->select('token')
->from(Token::class, 'token')
->where($dql->expr()->gte('token.validUntil', ':now'))
->andWhere($dql->expr()->eq('token.token', ':token'))
->andWhere($dql->expr()->eq('token.verb', ':verb'))
->setParameter('now', new DateTime())
->setParameter('token', $token)
->setParameter('verb', $verb)
->setMaxResults(1);
$query = $dql->getQuery();
/** @var ?Token */
return $query->getOneOrNullResult();
}
/**
* Get all sets.
*
* @param int $counter Counter for split result sets
*
* @return Result<Sets> The sets and possibly a resumption token
*/
public function getSets($counter = 0): Result
{
$result = [];
$maxRecords = Configuration::getInstance()->maxRecords;
$cursor = $counter * $maxRecords;
$dql = $this->entityManager->createQueryBuilder();
$dql->select('sets')
->from(Set::class, 'sets', 'sets.spec')
->setFirstResult($cursor)
->setMaxResults($maxRecords);
$query = $dql->getQuery();
$query->enableResultCache();
/** @var Sets */
$resultQuery = $query->getResult();
$result = new Result($resultQuery);
$paginator = new Paginator($query, false);
if (count($paginator) > ($cursor + count($result))) {
$token = new Token('ListSets', [
'counter' => $counter + 1,
'completeListSize' => count($paginator)
]);
$this->entityManager->persist($token);
$this->entityManager->flush();
$result->setResumptionToken($token);
}
return $result;
}
/**
* Check if a record identifier exists.
*
* @param string $identifier The record identifier
*
* @return bool Whether the identifier exists
*/
public function idDoesExist(string $identifier): bool
{
$dql = $this->entityManager->createQueryBuilder();
$dql->select('COUNT(record.identifier)')
->from(Record::class, 'record')
->where($dql->expr()->eq('record.identifier', ':identifier'))
->setParameter('identifier', $identifier)
->setMaxResults(1);
$query = $dql->getQuery();
return (bool) $query->getOneOrNullResult(AbstractQuery::HYDRATE_SINGLE_SCALAR);
}
/**
* Prune expired resumption tokens.
*
* @return int The number of deleted tokens
*/
public function pruneResumptionTokens(): int
{
$dql = $this->entityManager->createQueryBuilder();
$dql->delete(Token::class, 'token')
->where($dql->expr()->lt('token.validUntil', ':now'))
->setParameter('now', new DateTime());
$query = $dql->getQuery();
/** @var int */
return $query->execute();
}
/**
* Remove metadata format and all associated records.
*
* @param string $prefix The metadata prefix
*
* @return bool TRUE on success or FALSE on failure
*/
public function removeMetadataFormat(string $prefix): bool
{
$dql = $this->entityManager->createQueryBuilder();
$dql->delete(Format::class, 'format')
->where($dql->expr()->eq('format.prefix', ':prefix'))
->setParameter('prefix', $prefix);
$query = $dql->getQuery();
try {
$query->execute();
return true;
} catch (Exception) {
return false;
}
}
/**
* This is a singleton class, thus the constructor is private.
*
* Usage: Get an instance of this class by calling Database::getInstance()
*/
private function __construct()
{
$configuration = new DoctrineConfiguration();
$configuration->setAutoGenerateProxyClasses(
ProxyFactory::AUTOGENERATE_NEVER
);
$configuration->setMetadataCache(
new PhpFilesAdapter(
'Metadata',
0,
__DIR__ . '/../var/cache'
)
);
$configuration->setMetadataDriverImpl(
new AttributeDriver([__DIR__ . '/Database'])
);
$configuration->setProxyDir(__DIR__ . '/../var/generated');
$configuration->setProxyNamespace('OCC\OaiPmh2\Proxy');
$configuration->setQueryCache(
new PhpFilesAdapter(
'Query',
0,
__DIR__ . '/../var/cache'
)
);
$configuration->setResultCache(
new PhpFilesAdapter(
'Result',
0,
__DIR__ . '/../var/cache'
)
);
$configuration->setSchemaAssetsFilter(
static function(string|AbstractAsset $assetName): bool {
if ($assetName instanceof AbstractAsset) {
$assetName = $assetName->getName();
}
return in_array($assetName, self::DB_TABLES, true);
}
);
$baseDir = Path::canonicalize(__DIR__ . '/../');
$dsn = str_replace('%BASEDIR%', $baseDir, Configuration::getInstance()->database);
$parser = new DsnParser([
'mariadb' => 'pdo_mysql',
'mssql' => 'pdo_sqlsrv',
'mysql' => 'pdo_mysql',
'oracle' => 'pdo_oci',
'postgres' => 'pdo_pgsql',
'sqlite' => 'pdo_sqlite'
]);
$connection = DriverManager::getConnection($parser->parse($dsn), $configuration);
$this->entityManager = new EntityManager($connection, $configuration);
}
}

174
src/Database/Format.php Normal file
View File

@ -0,0 +1,174 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Database;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validation;
/**
* Doctrine/ORM Entity for formats.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
#[ORM\Entity]
#[ORM\Table(name: 'formats')]
class Format
{
/**
* The unique metadata prefix.
*/
#[ORM\Id]
#[ORM\Column(type: 'string')]
private string $prefix;
/**
* The format's namespace URI.
*/
#[ORM\Column(type: 'string')]
private string $namespace;
/**
* The format's schema URL.
*/
#[ORM\Column(type: 'string')]
private string $xmlSchema;
/**
* Collection of associated records.
*
* @var Collection<int, Record>
*/
#[ORM\OneToMany(targetEntity: Record::class, mappedBy: 'format')]
private Collection $records;
/**
* Update bi-directional association with records.
*
* @param Record $record The record to add to this format
*
* @return void
*/
public function addRecord(Record $record): void
{
if (!$this->records->contains($record)) {
$this->records->add($record);
}
}
/**
* Get the format's namespace URI.
*
* @return string The namespace URI
*/
public function getNamespace(): string
{
return $this->namespace;
}
/**
* Get the metadata prefix for this format.
*
* @return string The metadata prefix
*/
public function getPrefix(): string
{
return $this->prefix;
}
/**
* Get a collection of associated records.
*
* @return Collection<int, Record> The associated records
*/
public function getRecords(): Collection
{
return $this->records;
}
/**
* Get the format's schema URL.
*
* @return string The schema URL
*/
public function getSchema(): string
{
return $this->xmlSchema;
}
/**
* Update bi-directional association with records.
*
* @param Record $record The record to remove from this metadata prefix
*
* @return void
*/
public function removeRecord(Record $record): void
{
$this->records->removeElement($record);
}
/**
* Validate namespace and schema URLs.
*
* @param string $url The namespace or schema URL
*
* @return string The validated URL
*
* @throws ValidationFailedException
*/
protected function validate(string $url): string
{
$validator = Validation::createValidator();
$violations = $validator->validate($url, new Assert\Url());
if ($violations->count() > 0) {
throw new ValidationFailedException(null, $violations);
}
return $url;
}
/**
* Get new entity of format.
*
* @param string $prefix The metadata prefix
* @param string $namespace The format's namespace URI
* @param string $schema The format's schema URL
*
* @throws ValidationFailedException
*/
public function __construct(string $prefix, string $namespace, string $schema)
{
try {
$this->prefix = $prefix;
$this->namespace = $this->validate($namespace);
$this->xmlSchema = $this->validate($schema);
$this->records = new ArrayCollection();
} catch (ValidationFailedException $exception) {
throw $exception;
}
}
}

263
src/Database/Record.php Normal file
View File

@ -0,0 +1,263 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Database;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validation;
/**
* Doctrine/ORM Entity for records.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
#[ORM\Entity]
#[ORM\Table(name: 'records')]
class Record
{
/**
* The record identifier.
*/
#[ORM\Id]
#[ORM\Column(type: 'string')]
private string $identifier;
/**
* The associated format.
*/
#[ORM\Id]
#[ORM\ManyToOne(targetEntity: Format::class, inversedBy: 'records')]
#[ORM\JoinColumn(name: 'format', referencedColumnName: 'prefix')]
private Format $format;
/**
* The date and time of last change.
*/
#[ORM\Column(name: 'last_changed', type: 'datetime')]
private DateTime $lastChanged;
/**
* The record's content.
*/
#[ORM\Column(type: 'text')]
private string $content = '';
/**
* Collection of associated sets.
*
* @var Collection<int, Set>
*/
#[ORM\ManyToMany(targetEntity: Set::class, inversedBy: 'records', indexBy: 'spec')]
#[ORM\JoinTable(name: 'records_sets')]
#[ORM\JoinColumn(name: 'record_identifier', referencedColumnName: 'identifier')]
#[ORM\JoinColumn(name: 'record_format', referencedColumnName: 'format')]
#[ORM\InverseJoinColumn(name: 'set_spec', referencedColumnName: 'spec')]
private Collection $sets;
/**
* Get the record's content.
*
* @return string The record's content
*/
public function getContent(): string
{
return $this->content;
}
/**
* Get the record identifier.
*
* @return string The record identifier
*/
public function getIdentifier(): string
{
return $this->identifier;
}
/**
* Update bi-directional association with format.
*
* @param Format $format The metadata prefix
*
* @return void
*/
private function addFormat(Format $format): void
{
$this->format = $format;
$format->addRecord($this);
}
/**
* Associate the record with a set.
*
* @param Set $set The set
*
* @return void
*/
public function addSet(Set $set): void
{
if (!$this->sets->contains($set)) {
$this->sets->add($set);
$set->addRecord($this);
}
}
/**
* Get the associated format.
*
* @return Format The associated format
*/
public function getFormat(): Format
{
return $this->format;
}
/**
* Get the date and time of last change.
*
* @return DateTime The datetime of last change
*/
public function getLastChanged(): DateTime
{
return $this->lastChanged;
}
/**
* Get a collection of associated sets.
*
* @return Collection<int, Set> The associated sets
*/
public function getSets(): Collection
{
return $this->sets;
}
/**
* Remove record from set.
*
* @param Set $set The set
*
* @return void
*/
public function removeSet(Set $set): void
{
if ($this->sets->contains($set)) {
$this->sets->removeElement($set);
$set->removeRecord($this);
}
}
/**
* Set record's content.
*
* @param string $data The record's content
* @param bool $validate Should the input be validated?
*
* @return void
*
* @throws ValidationFailedException
*/
public function setContent(string $data, bool $validate = true): void
{
$data = trim($data);
if ($validate && $data !== '') {
try {
$data = $this->validate($data);
} catch (ValidationFailedException $exception) {
throw $exception;
}
}
$this->content = $data;
}
/**
* Set date and time of last change.
*
* @param ?DateTime $dateTime The datetime of last change or NULL for "NOW"
*
* @return void
*/
public function setLastChanged(?DateTime $dateTime = null): void
{
if (!isset($dateTime)) {
$dateTime = new DateTime();
}
$this->lastChanged = $dateTime;
}
/**
* Validate XML content.
*
* @param string $xml The XML string
*
* @return string The validated XML string
*
* @throws ValidationFailedException
*/
protected function validate(string $xml): string
{
$validator = Validation::createValidator();
$violations = $validator->validate($xml, new Assert\Type('string'));
if ($violations->count() > 0) {
throw new ValidationFailedException(null, $violations);
}
return $xml;
}
/**
* Get new entity of record.
*
* @param string $identifier The record identifier
* @param Format $format The format
* @param string $data The record's content
*
* @throws ValidationFailedException
*/
public function __construct(string $identifier, Format $format, string $data = '')
{
try {
$this->identifier = $identifier;
$this->addFormat($format);
$this->setContent($data);
$this->setLastChanged();
$this->sets = new ArrayCollection();
} catch (ValidationFailedException $exception) {
throw $exception;
}
}
/**
* Get the record's content.
*
* @return string The record's content
*/
public function __toString(): string
{
return $this->content;
}
}

97
src/Database/Result.php Normal file
View File

@ -0,0 +1,97 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Database;
use Countable;
use Iterator;
use OCC\Basics\InterfaceTraits\Countable as CountableTrait;
use OCC\Basics\InterfaceTraits\Iterator as IteratorTrait;
/**
* A database result set with optional resumption token.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*
* @template QueryResult of array<string, Format|Record|Set>
* @implements Iterator<QueryResult>
*/
class Result implements Countable, Iterator
{
use CountableTrait;
use IteratorTrait;
/**
* This holds the Doctrine result set.
*
* @var QueryResult
*/
private array $data = [];
/**
* This holds the optional resumption token.
*/
protected ?Token $resumptionToken;
/**
* Get the query result.
*
* @return QueryResult The result set
*/
public function getQueryResult(): array
{
return $this->data;
}
/**
* Get the resumption token.
*
* @return ?Token The resumption token or NULL if not applicable
*/
public function getResumptionToken(): ?Token
{
return $this->resumptionToken;
}
/**
* Set the resumption token.
*
* @param Token $token The resumption token
*
* @return void
*/
public function setResumptionToken(Token $token): void
{
$this->resumptionToken = $token;
}
/**
* Create new result set.
*
* @param QueryResult $queryResult The Doctrine result set
*/
public function __construct(array $queryResult)
{
$this->data = $queryResult;
}
}

158
src/Database/Set.php Normal file
View File

@ -0,0 +1,158 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Database;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* Doctrine/ORM Entity for sets.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
#[ORM\Entity]
#[ORM\Table(name: 'sets')]
class Set
{
/**
* The unique set spec.
*/
#[ORM\Id]
#[ORM\Column(type: 'string')]
private string $spec;
/**
* The name of the set.
*/
#[ORM\Column(type: 'string')]
private string $name;
/**
* A description of the set.
*/
#[ORM\Column(type: 'text')]
private string $description = '';
/**
* Collection of associated records.
*
* @var Collection<int, Record>
*/
#[ORM\ManyToMany(targetEntity: Record::class, mappedBy: 'sets')]
private Collection $records;
/**
* Update bi-directional association with records.
*
* @param Record $record The record to add to this set
*
* @return void
*/
public function addRecord(Record $record): void
{
if (!$this->records->contains($record)) {
$this->records->add($record);
}
}
/**
* Get the description of this set.
*
* @return string The set description
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Get the name of this set.
*
* @return string The set name
*/
public function getName(): string
{
return $this->name;
}
/**
* Get the set spec.
*
* @return string The set spec
*/
public function getSpec(): string
{
return $this->spec;
}
/**
* Get a collection of associated records.
*
* @return Collection<int, Record> The associated records
*/
public function getRecords(): Collection
{
return $this->records;
}
/**
* Update bi-directional association with records.
*
* @param Record $record The record to remove from this set
*
* @return void
*/
public function removeRecord(Record $record): void
{
$this->records->removeElement($record);
}
/**
* Set the description for this set.
*
* @param string $description The description
*
* @return void
*/
public function setDescription(string $description): void
{
$this->description = $description;
}
/**
* Get new entity of set.
*
* @param string $spec The set spec
* @param string $name The name of the set
* @param string $description The description of the set
*/
public function __construct(string $spec, string $name, string $description = '')
{
$this->spec = $spec;
$this->name = $name;
$this->setDescription($description);
$this->records = new ArrayCollection();
}
}

121
src/Database/Token.php Normal file
View File

@ -0,0 +1,121 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Database;
use DateInterval;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use OCC\OaiPmh2\Configuration;
/**
* Doctrine/ORM Entity for resumption tokens.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
#[ORM\Entity]
#[ORM\Table(name: 'tokens')]
class Token
{
/**
* The resumption token.
*/
#[ORM\Id]
#[ORM\Column(type: 'string')]
private string $token;
/**
* The verb for which the token is issued.
*/
#[ORM\Column(type: 'string')]
private string $verb;
/**
* The query parameters as serialized array.
*/
#[ORM\Column(type: 'string')]
private string $parameters;
/**
* The date and time of validity.
*/
#[ORM\Column(name: 'valid_until', type: 'datetime')]
private DateTime $validUntil;
/**
* Get the resumption token.
*
* @return string The resumption token
*/
public function getToken(): string
{
return $this->token;
}
/**
* Get the query parameters.
*
* @return array<string, int|string|null> The query parameters
*/
public function getParameters(): array
{
/** @var array<string, int|string|null> */
return unserialize($this->parameters);
}
/**
* Get the date and time of validity.
*
* @return DateTime The datetime of validity
*/
public function getValidUntil(): DateTime
{
return $this->validUntil;
}
/**
* Get the verb for which the token was issued.
*
* @return string The verb
*/
public function getVerb(): string
{
return $this->verb;
}
/**
* Get new entity of resumption token.
*
* @param string $verb The verb for which the token is issued
* @param array<string, int|string|null> $parameters The query parameters
*/
public function __construct(string $verb, array $parameters)
{
$this->token = substr(md5(microtime()), 0, 8);
$this->verb = $verb;
$this->parameters = serialize($parameters);
$validity = new DateTime();
$validity->add(new DateInterval('PT' . Configuration::getInstance()->tokenValid . 'S'));
$this->validUntil = $validity;
}
}

181
src/Document.php Normal file
View File

@ -0,0 +1,181 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2;
use DOMDocument;
use DOMElement;
use DOMException;
use DOMNode;
use GuzzleHttp\Psr7\Uri;
use Psr\Http\Message\ServerRequestInterface;
/**
* An OAI-PMH XML response object.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
class Document
{
/**
* This holds the DOMDocument of the OAI-PMH XML response.
*/
protected DOMDocument $dom;
/**
* This holds the root node of the OAI-PMH XML response.
*/
protected DOMElement $rootNode;
/**
* Create a new XML element.
*
* @param string $localName The local name for the element
* @param string $value The optional value for the element
* @param bool $appendToRoot Append the new element to the root node?
*
* @return DOMElement The newly created element
*/
public function createElement(string $localName, string $value = '', bool $appendToRoot = false): DOMElement
{
$node = $this->dom->createElement(
$localName,
htmlspecialchars($value, ENT_XML1, 'UTF-8')
);
if ($appendToRoot) {
$this->rootNode->appendChild($node);
}
return $node;
}
/**
* Import XML data into response document.
*
* @param string $data The XML data
*
* @return DOMNode The imported XML node
*
* @throws DOMException
*/
public function importData(string $data): DOMNode
{
$document = new DOMDocument('1.0', 'UTF-8');
$document->preserveWhiteSpace = false;
if ($document->loadXML($data) === true) {
/** @var DOMElement */
$rootNode = $document->documentElement;
$node = $this->dom->importNode($rootNode, true);
return $node;
} else {
throw new DOMException(
'Could not import the XML data. Most likely it is not well-formed.',
500
);
}
}
/**
* Create an OAI-PMH XML response.
*
* @param ServerRequestInterface $serverRequest The PSR-7 HTTP Server Request
*/
public function __construct(ServerRequestInterface $serverRequest)
{
$uri = $serverRequest->getUri();
// Create XML document.
$this->dom = new DOMDocument('1.0', 'UTF-8');
$this->dom->preserveWhiteSpace = false;
// Add processing instructions.
$basePath = $uri->getPath();
if (str_ends_with($basePath, 'index.php')) {
$basePath = pathinfo($basePath, PATHINFO_DIRNAME);
}
$stylesheet = Uri::composeComponents(
$uri->getScheme(),
$uri->getAuthority(),
rtrim($basePath, '/') . '/resources/stylesheet.xsl',
null,
null
);
$xslt = $this->dom->createProcessingInstruction(
'xml-stylesheet',
sprintf(
'type="text/xsl" href="%s"',
$stylesheet
)
);
$this->dom->appendChild($xslt);
// Add root element "OAI-PMH".
$root = $this->dom->createElement('OAI-PMH');
$this->dom->appendChild($root);
$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/ https://www.openarchives.org/OAI/2.0/OAI-PMH.xsd'
);
// Add element "responseDate".
$responseDate = $this->dom->createElement('responseDate', gmdate('Y-m-d\TH:i:s\Z'));
$root->appendChild($responseDate);
// Add element "request".
$baseUrl = Uri::composeComponents(
$uri->getScheme(),
$uri->getAuthority(),
$uri->getPath(),
null,
null
);
$request = $this->dom->createElement('request', $baseUrl);
$root->appendChild($request);
foreach ($serverRequest->getAttributes() as $param => $value) {
$request->setAttribute(
$param,
htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8')
);
}
$this->rootNode = $root;
}
/**
* Serialize the OAI-PMH XML response.
*
* @return string The XML output
*/
public function __toString(): string
{
$this->dom->formatOutput = true;
return (string) $this->dom->saveXML();
}
}

80
src/Middleware.php Normal file
View File

@ -0,0 +1,80 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2;
use GuzzleHttp\Psr7\Utils;
use OCC\OaiPmh2\Middleware\ErrorHandler;
use OCC\PSR15\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Base class for all OAI-PMH requests.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
abstract class Middleware extends AbstractMiddleware
{
/**
* This holds the prepared response document.
*/
protected Document $preparedResponse;
/**
* Prepare response document.
*
* @param ServerRequestInterface $request The pre-processed request
*
* @return void
*/
abstract protected function prepareResponse(ServerRequestInterface $request): void;
/**
* Process an incoming server request.
*
* @param ServerRequestInterface $request The incoming server request
*
* @return ServerRequestInterface The processed server request
*/
protected function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
$this->prepareResponse($request);
return $request;
}
/**
* Process an incoming response before.
*
* @param ResponseInterface $response The incoming response
*
* @return ResponseInterface The processed response
*/
protected function processResponse(ResponseInterface $response): ResponseInterface
{
if (!ErrorHandler::getInstance()->hasErrors() && isset($this->preparedResponse)) {
$response = $response->withBody(Utils::streamFor((string) $this->preparedResponse));
}
return $response;
}
}

View File

@ -0,0 +1,176 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\Middleware;
use OCC\PSR15\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Validate and dispatch a OAI-PMH server request.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
class Dispatcher extends AbstractMiddleware
{
/**
* List of defined OAI-PMH parameters.
*/
protected const OAI_PARAMS = [
'verb',
'identifier',
'metadataPrefix',
'from',
'until',
'set',
'resumptionToken'
];
/**
* Get server request populated with request attributes.
*
* @param ServerRequestInterface $request The GET or POST request
*
* @return ServerRequestInterface The same request with parsed attributes
*/
protected function getRequestWithAttributes(ServerRequestInterface $request): ServerRequestInterface
{
$arguments = [];
if ($request->getMethod() === 'GET') {
$arguments = $request->getQueryParams();
} elseif ($request->getMethod() === 'POST') {
if ($request->getHeaderLine('Content-Type') === 'application/x-www-form-urlencoded') {
$arguments = (array) $request->getParsedBody();
}
}
if ($this->validateArguments($arguments)) {
foreach ($arguments as $param => $value) {
$request = $request->withAttribute($param, $value);
}
}
return $request;
}
/**
* Dispatch the OAI-PMH request.
*
* @param ServerRequestInterface $request The request to dispatch
*
* @return ServerRequestInterface The processed server request
*/
protected function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
$request = $this->getRequestWithAttributes($request);
if (!ErrorHandler::getInstance()->hasErrors()) {
/** @var Middleware $middleware */
$middleware = __NAMESPACE__ . '\\' . $request->getAttribute('verb');
$this->requestHandler->queue->enqueue(new $middleware());
}
$this->requestHandler->queue->enqueue(ErrorHandler::getInstance());
return $request;
}
/**
* Finalize the OAI-PMH response.
*
* @param ResponseInterface $response The response to finalize
*
* @return ResponseInterface The final response
*/
protected function processResponse(ResponseInterface $response): ResponseInterface
{
// TODO: Add support for content compression
// https://openarchives.org/OAI/openarchivesprotocol.html#ResponseCompression
return $response->withHeader('Content-Type', 'text/xml');
}
/**
* Validate the request parameters.
* @see https://openarchives.org/OAI/openarchivesprotocol.html#ProtocolMessages
*
* @param string[] $arguments The request parameters
*
* @return bool Whether the parameters are syntactically valid
*/
protected function validateArguments(array $arguments): bool
{
if (
count(array_diff(array_keys($arguments), self::OAI_PARAMS)) !== 0
or !isset($arguments['verb'])
) {
ErrorHandler::getInstance()->withError('badArgument');
} else {
switch ($arguments['verb']) {
case 'GetRecord':
if (
count($arguments) !== 3
or !isset($arguments['identifier'])
or !isset($arguments['metadataPrefix'])
) {
ErrorHandler::getInstance()->withError('badArgument');
}
break;
case 'Identify':
if (count($arguments) !== 1) {
ErrorHandler::getInstance()->withError('badArgument');
}
break;
case 'ListIdentifiers':
case 'ListRecords':
if (
isset($arguments['metadataPrefix'])
xor isset($arguments['resumptionToken'])
) {
if (
(isset($arguments['resumptionToken']) && count($arguments) !== 2)
or isset($arguments['identifier'])
) {
ErrorHandler::getInstance()->withError('badArgument');
}
} else {
ErrorHandler::getInstance()->withError('badArgument');
}
break;
case 'ListMetadataFormats':
if (count($arguments) !== 1) {
if (!isset($arguments['identifier']) || count($arguments) !== 2) {
ErrorHandler::getInstance()->withError('badArgument');
}
}
break;
case 'ListSets':
if (count($arguments) !== 1) {
if (!isset($arguments['resumptionToken']) || count($arguments) !== 2) {
ErrorHandler::getInstance()->withError('badArgument');
}
}
break;
default:
ErrorHandler::getInstance()->withError('badVerb');
}
}
return !ErrorHandler::getInstance()->hasErrors();
}
}

View File

@ -0,0 +1,138 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware;
use DomainException;
use GuzzleHttp\Psr7\Utils;
use OCC\Basics\Traits\Singleton;
use OCC\OaiPmh2\Document;
use OCC\PSR15\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
/**
* Handles OAI-PMH errors.
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
class ErrorHandler extends AbstractMiddleware
{
use Singleton;
/**
* List of defined OAI-PMH errors.
* @see https://openarchives.org/OAI/openarchivesprotocol.html#ErrorConditions
*/
protected const OAI_ERRORS = [
'badArgument' => 'The request includes illegal arguments, is missing required arguments, includes a repeated argument, or values for arguments have an illegal syntax.',
'badResumptionToken' => 'The value of the resumptionToken argument is invalid or expired.',
'badVerb' => 'Value of the verb argument is not a legal OAI-PMH verb, the verb argument is missing, or the verb argument is repeated.',
'cannotDisseminateFormat' => 'The metadata format identified by the value given for the metadataPrefix argument is not supported by the item or by the repository.',
'idDoesNotExist' => 'The value of the identifier argument is unknown or illegal in this repository.',
'noRecordsMatch' => 'The combination of the values of the from, until, set and metadataPrefix arguments results in an empty list.',
'noMetadataFormats' => 'There are no metadata formats available for the specified item.',
'noSetHierarchy' => 'The repository does not support sets.'
];
/**
* The current error codes.
*
* @var string[] $errors
*/
protected array $errors = [];
/**
* Prepare the response body.
*
* @return StreamInterface The response body stream
*/
protected function getResponseBody(): StreamInterface
{
$document = new Document($this->requestHandler->request);
foreach (array_unique($this->errors) as $errorCode) {
$error = $document->createElement('error', self::OAI_ERRORS[$errorCode], true);
$error->setAttribute('code', $errorCode);
}
return Utils::streamFor((string) $document);
}
/**
* Check if currently there are errors to handle.
*
* @return bool Whether the error handler has any errors registered
*/
public function hasErrors(): bool
{
return (bool) count($this->errors);
}
/**
* Generate an error response if errors occured.
*
* @param ResponseInterface $response The incoming response
*
* @return ResponseInterface The error response
*/
protected function processResponse(ResponseInterface $response): ResponseInterface
{
if ($this->hasErrors()) {
$response = $response->withBody($this->getResponseBody());
}
return $response;
}
/**
* Delegate an OAI-PMH error to the error handler.
*
* @param string $errorCode The error code to handle
*
* @return ErrorHandler The ErrorHandler instance
*
* @throws DomainException
*/
public function withError(string $errorCode): ErrorHandler
{
if (in_array($errorCode, array_keys(self::OAI_ERRORS), true)) {
$this->errors[] = $errorCode;
} else {
throw new DomainException(
sprintf(
'Valid OAI-PMH error code expected, "%s" given.',
$errorCode
),
500
);
}
return $this;
}
/**
* This is a singleton class, thus the constructor is private.
*
* Usage: Get an instance by calling ErrorHandler::getInstance()
*/
private function __construct()
{
}
}

View File

@ -0,0 +1,93 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Document;
use OCC\OaiPmh2\Middleware;
use Psr\Http\Message\ServerRequestInterface;
/**
* Process the "GetRecord" request.
* @see https://www.openarchives.org/OAI/openarchivesprotocol.html#GetRecord
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
class GetRecord extends Middleware
{
/**
* Prepare the response body for verb "GetRecord".
*
* @param ServerRequestInterface $request The incoming request
*
* @return void
*/
protected function prepareResponse(ServerRequestInterface $request): void
{
$params = $request->getAttributes();
$oaiRecord = Database::getInstance()->getRecord($params['identifier'], $params['metadataPrefix']);
if (!isset($oaiRecord)) {
if (Database::getInstance()->idDoesExist($params['identifier'])) {
ErrorHandler::getInstance()->withError('cannotDisseminateFormat');
} else {
ErrorHandler::getInstance()->withError('idDoesNotExist');
}
return;
}
$document = new Document($request);
$getRecord = $document->createElement('GetRecord', '', true);
$record = $document->createElement('record');
$getRecord->appendChild($record);
$header = $document->createElement('header');
if ($oaiRecord->getContent() === '') {
$header->setAttribute('status', 'deleted');
}
$record->appendChild($header);
$identifier = $document->createElement('identifier', $oaiRecord->getIdentifier());
$header->appendChild($identifier);
$datestamp = $document->createElement('datestamp', $oaiRecord->getLastChanged()->format('Y-m-d\TH:i:s\Z'));
$header->appendChild($datestamp);
foreach ($oaiRecord->getSets() as $set) {
$setSpec = $document->createElement('setSpec', $set->getName());
$header->appendChild($setSpec);
}
if ($oaiRecord->getContent() !== '') {
$metadata = $document->createElement('metadata');
$record->appendChild($metadata);
$data = $document->importData($oaiRecord->getContent());
$metadata->appendChild($data);
}
$this->preparedResponse = $document;
}
}

View File

@ -0,0 +1,90 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware;
use GuzzleHttp\Psr7\Uri;
use OCC\OaiPmh2\Configuration;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Document;
use OCC\OaiPmh2\Middleware;
use Psr\Http\Message\ServerRequestInterface;
/**
* Process the "Identify" request.
* @see https://www.openarchives.org/OAI/openarchivesprotocol.html#Identify
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
class Identify extends Middleware
{
/**
* Prepare the response body for verb "Identify".
*
* @param ServerRequestInterface $request The incoming request
*
* @return void
*/
protected function prepareResponse(ServerRequestInterface $request): void
{
$document = new Document($request);
$identify = $document->createElement('Identify', '', true);
$name = Configuration::getInstance()->repositoryName;
$repositoryName = $document->createElement('repositoryName', $name);
$identify->appendChild($repositoryName);
$uri = Uri::composeComponents(
$request->getUri()->getScheme(),
$request->getUri()->getAuthority(),
$request->getUri()->getPath(),
null,
null
);
$baseURL = $document->createElement('baseURL', $uri);
$identify->appendChild($baseURL);
$protocolVersion = $document->createElement('protocolVersion', '2.0');
$identify->appendChild($protocolVersion);
$email = Configuration::getInstance()->adminEmail;
$adminEmail = $document->createElement('adminEmail', $email);
$identify->appendChild($adminEmail);
$datestamp = Database::getInstance()->getEarliestDatestamp();
$earliestDatestamp = $document->createElement('earliestDatestamp', $datestamp);
$identify->appendChild($earliestDatestamp);
$deletedRecord = $document->createElement('deletedRecord', 'transient');
$identify->appendChild($deletedRecord);
$granularity = $document->createElement('granularity', 'YYYY-MM-DDThh:mm:ssZ');
$identify->appendChild($granularity);
// TODO: Add support for content compression
// $compression = $document->createElement('compression', '...');
// $identify->appendChild($compression);
$this->preparedResponse = $document;
}
}

View File

@ -0,0 +1,156 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\Configuration;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Database\Record;
use OCC\OaiPmh2\Document;
use OCC\OaiPmh2\Middleware;
use Psr\Http\Message\ServerRequestInterface;
/**
* Process the "ListIdentifiers" request.
* @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListIdentifiers
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
class ListIdentifiers extends Middleware
{
/**
* Prepare the response body for verb "ListIdentifiers" and "ListRecords".
*
* @param ServerRequestInterface $request The incoming request
*
* @return void
*/
protected function prepareResponse(ServerRequestInterface $request): void
{
$counter = 0;
$completeListSize = 0;
$maxRecords = Configuration::getInstance()->maxRecords;
$params = $request->getAttributes();
$verb = $params['verb'];
$metadataPrefix = $params['metadataPrefix'] ?? '';
$from = $params['from'] ?? null;
$until = $params['until'] ?? null;
$set = $params['set'] ?? null;
$resumptionToken = $params['resumptionToken'] ?? null;
if (isset($resumptionToken)) {
$oldToken = Database::getInstance()->getResumptionToken($resumptionToken, $verb);
if (!isset($oldToken)) {
ErrorHandler::getInstance()->withError('badResumptionToken');
return;
} else {
foreach ($oldToken->getParameters() as $key => $value) {
$$key = $value;
}
}
}
$prefixes = Database::getInstance()->getMetadataFormats();
if (!in_array($metadataPrefix, array_keys($prefixes->getQueryResult()), true)) {
ErrorHandler::getInstance()->withError('cannotDisseminateFormat');
return;
}
if (isset($set)) {
$sets = Database::getInstance()->getSets();
if (!in_array($set, array_keys($sets->getQueryResult()), true)) {
ErrorHandler::getInstance()->withError('noSetHierarchy');
return;
}
}
$records = Database::getInstance()->getRecords($verb, $metadataPrefix, $counter, $from, $until, $set);
if (count($records) === 0) {
ErrorHandler::getInstance()->withError('noRecordsMatch');
return;
} elseif ($records->getResumptionToken() !== null) {
$newToken = $records->getResumptionToken();
$completeListSize = $newToken->getParameters()['completeListSize'];
}
$document = new Document($request);
$list = $document->createElement($verb, '', true);
/** @var Record $oaiRecord */
foreach ($records as $oaiRecord) {
if ($verb === 'ListIdentifiers') {
$baseNode = $list;
} else {
$record = $document->createElement('record');
$list->appendChild($record);
$baseNode = $record;
}
$header = $document->createElement('header');
if ($oaiRecord->getContent() === '') {
$header->setAttribute('status', 'deleted');
}
$baseNode->appendChild($header);
$identifier = $document->createElement('identifier', $oaiRecord->getIdentifier());
$header->appendChild($identifier);
$datestamp = $document->createElement('datestamp', $oaiRecord->getLastChanged()->format('Y-m-d\TH:i:s\Z'));
$header->appendChild($datestamp);
foreach ($oaiRecord->getSets() as $oaiSet) {
$setSpec = $document->createElement('setSpec', $oaiSet->getName());
$header->appendChild($setSpec);
}
if ($verb === 'ListRecords' && $oaiRecord->getContent() !== '') {
$metadata = $document->createElement('metadata');
$baseNode->appendChild($metadata);
$data = $document->importData($oaiRecord->getContent());
$metadata->appendChild($data);
}
}
if (isset($oldToken) || isset($newToken)) {
$resumptionToken = $document->createElement('resumptionToken');
$list->appendChild($resumptionToken);
if (isset($newToken)) {
$resumptionToken->nodeValue = $newToken->getToken();
$resumptionToken->setAttribute(
'expirationDate',
$newToken->getValidUntil()->format('Y-m-d\TH:i:s\Z')
);
}
$resumptionToken->setAttribute(
'completeListSize',
(string) $completeListSize
);
$resumptionToken->setAttribute(
'cursor',
(string) ($counter * $maxRecords)
);
}
$this->preparedResponse = $document;
}
}

View File

@ -0,0 +1,82 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Database\Format;
use OCC\OaiPmh2\Document;
use OCC\OaiPmh2\Middleware;
use Psr\Http\Message\ServerRequestInterface;
/**
* Process the "ListMetadataFormats" request.
* @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListMetadataFormats
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
class ListMetadataFormats extends Middleware
{
/**
* Prepare the response body for verb "ListMetadataFormats".
*
* @param ServerRequestInterface $request The incoming request
*
* @return void
*/
protected function prepareResponse(ServerRequestInterface $request): void
{
/** @var ?string */
$identifier = $request->getAttribute('identifier');
$formats = Database::getInstance()->getMetadataFormats($identifier);
if (count($formats) === 0) {
if (!isset($identifier) || Database::getInstance()->idDoesExist($identifier)) {
ErrorHandler::getInstance()->withError('noMetadataFormats');
} else {
ErrorHandler::getInstance()->withError('idDoesNotExist');
}
return;
}
$document = new Document($request);
$listMetadataFormats = $document->createElement('ListMetadataFormats', '', true);
/** @var Format $oaiFormat */
foreach ($formats as $oaiFormat) {
$metadataFormat = $document->createElement('metadataFormat');
$listMetadataFormats->appendChild($metadataFormat);
$metadataPrefix = $document->createElement('metadataPrefix', $oaiFormat->getPrefix());
$metadataFormat->appendChild($metadataPrefix);
$schema = $document->createElement('schema', $oaiFormat->getSchema());
$metadataFormat->appendChild($schema);
$metadataNamespace = $document->createElement('metadataNamespace', $oaiFormat->getNamespace());
$metadataFormat->appendChild($metadataNamespace);
}
$this->preparedResponse = $document;
}
}

View File

@ -0,0 +1,39 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware;
/**
* Process the "ListRecords" request.
* @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListRecords
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*/
class ListRecords extends ListIdentifiers
{
/**
* "ListIdentifiers" and "ListRecords" are practically identical except the
* former returns the header information only while the latter also returns
* the records' data.
*/
}

122
src/Middleware/ListSets.php Normal file
View File

@ -0,0 +1,122 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2023 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\Configuration;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Database\Set;
use OCC\OaiPmh2\Database\Token;
use OCC\OaiPmh2\Document;
use OCC\OaiPmh2\Middleware;
use Psr\Http\Message\ServerRequestInterface;
/**
* Process the "ListSets" request.
* @see https://openarchives.org/OAI/openarchivesprotocol.html#ListSets
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package opencultureconsulting/oai-pmh2
*
* @template Sets of array<string, Set>
*/
class ListSets extends Middleware
{
/**
* Prepare the response body for verb "ListSets".
*
* @param ServerRequestInterface $request The incoming request
*
* @return void
*/
protected function prepareResponse(ServerRequestInterface $request): void
{
$counter = 0;
$completeListSize = 0;
$maxRecords = Configuration::getInstance()->maxRecords;
/** @var ?string */
$token = $request->getAttribute('resumptionToken');
if (isset($token)) {
$oldToken = Database::getInstance()->getResumptionToken($token, 'ListSets');
if (!isset($oldToken)) {
ErrorHandler::getInstance()->withError('badResumptionToken');
return;
} else {
foreach ($oldToken->getParameters() as $key => $value) {
$$key = $value;
}
}
}
$sets = Database::getInstance()->getSets($counter);
if (count($sets) === 0) {
ErrorHandler::getInstance()->withError('noSetHierarchy');
return;
} elseif ($sets->getResumptionToken() !== null) {
$newToken = $sets->getResumptionToken();
$completeListSize = $newToken->getParameters()['completeListSize'];
}
$document = new Document($request);
$list = $document->createElement('ListSets', '', true);
/** @var Set $oaiSet */
foreach ($sets as $oaiSet) {
$set = $document->createElement('set');
$list->appendChild($set);
$setSpec = $document->createElement('setSpec', $oaiSet->getSpec());
$set->appendChild($setSpec);
$setName = $document->createElement('setName', $oaiSet->getName());
$set->appendChild($setName);
if ($oaiSet->getDescription() !== '') {
$setDescription = $document->createElement('setDescription', $oaiSet->getDescription());
$set->appendChild($setDescription);
}
}
if (isset($oldToken) || isset($newToken)) {
$resumptionToken = $document->createElement('resumptionToken');
$list->appendChild($resumptionToken);
if (isset($newToken)) {
$resumptionToken->nodeValue = $newToken->getToken();
$resumptionToken->setAttribute(
'expirationDate',
$newToken->getValidUntil()->format('Y-m-d\TH:i:s\Z')
);
}
$resumptionToken->setAttribute(
'completeListSize',
(string) $completeListSize
);
$resumptionToken->setAttribute(
'cursor',
(string) ($counter * $maxRecords)
);
}
$this->preparedResponse = $document;
}
}