From 90eac44d59d8a013d48dd736c7b376c34aef0446 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Wed, 6 Nov 2024 14:05:53 +0100 Subject: [PATCH 1/3] Validate publications with json schema --- composer.json | 3 +- composer.lock | 192 +++++++++++++++++++++++++++++- lib/Db/PublicationType.php | 23 ++++ lib/Service/ObjectService.php | 11 ++ lib/Service/ValidationService.php | 110 ++++------------- 5 files changed, 252 insertions(+), 87 deletions(-) diff --git a/composer.json b/composer.json index 89c318a1..9672442e 100644 --- a/composer.json +++ b/composer.json @@ -44,8 +44,9 @@ "adbario/php-dot-notation": "^3.3.0", "bamarni/composer-bin-plugin": "^1.8", "elasticsearch/elasticsearch": "^v8.14.0", - "guzzlehttp/guzzle": "^7.0", + "guzzlehttp/guzzle": "^7.0", "mpdf/mpdf": "^8.2", + "opis/json-schema": "^2.3", "symfony/twig-bundle": "^6.4", "symfony/uid": "^6.4" }, diff --git a/composer.lock b/composer.lock index 7284d015..8b03b3e9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "93ceb09bbb9b85fb7d77d68d6d6eefe1", + "content-hash": "bd1fa2147c63c25a4c1759758bcf0fbb", "packages": [ { "name": "adbario/php-dot-notation", @@ -788,6 +788,196 @@ ], "time": "2024-06-12T14:39:25+00:00" }, + { + "name": "opis/json-schema", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.0", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.3.0" + }, + "time": "2022-01-08T20:38:03+00:00" + }, + { + "name": "opis/string", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "9ebf1a1f873f502f6859d11210b25a4bf5d141e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/9ebf1a1f873f502f6859d11210b25a4bf5d141e7", + "reference": "9ebf1a1f873f502f6859d11210b25a4bf5d141e7", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.0.1" + }, + "time": "2022-01-14T15:42:23+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, { "name": "paragonie/random_compat", "version": "v9.99.100", diff --git a/lib/Db/PublicationType.php b/lib/Db/PublicationType.php index 510a1e6c..36e409d9 100644 --- a/lib/Db/PublicationType.php +++ b/lib/Db/PublicationType.php @@ -5,6 +5,7 @@ use DateTime; use JsonSerializable; use OCP\AppFramework\Db\Entity; +use OCP\IURLGenerator; class PublicationType extends Entity implements JsonSerializable { @@ -122,4 +123,26 @@ public function jsonSerialize(): array return $array; } + + public function getSchema(IURLGenerator $urlGenerator): object + { + $schema = []; + $schema['$schema'] = 'https://json-schema.org/draft/2020-12/schema'; + $schema['$id'] = $urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('openregister.Schemas.show', ['id' => $this->getUuid()])); + $schema['type'] = 'object'; + $schema['required'] = []; + $schema['properties'] = []; + + foreach($this->getProperties() as $name => $property) { + if ($property['required'] === true) { + $schema['required'][] = $name; + } + unset($property['title'], $property['required']); + + // Remove empty fields with array_filter(), and add it to the properties of the schema. + $schema['properties'][$name] = array_filter($property); + } + + return json_decode(json_encode($schema)); + } } diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index f0688c0b..b8084d7a 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -12,6 +12,7 @@ use OCP\App\IAppManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\IURLGenerator; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use Symfony\Component\Uid\Uuid; @@ -34,6 +35,8 @@ class ObjectService /** @var string $appName The name of the app */ private string $appName; + private ValidationService $validationService; + /** * Constructor for ObjectService. * @@ -59,8 +62,11 @@ public function __construct( private ContainerInterface $container, private readonly IAppManager $appManager, private readonly IAppConfig $config, + IURLGenerator $urlGenerator, ) { $this->appName = 'opencatalogi'; + + $this->validationService = new ValidationService(objectService: $this, urlGenerator: $urlGenerator); } /** @@ -300,6 +306,11 @@ public function saveObject(string $objectType, array $object, bool $updateVersio { // Get the appropriate mapper for the object type $mapper = $this->getMapper($objectType); + + if($objectType === 'publication') { + $object = $this->validationService->validatePublication($object); + } + // If the object has an id, update it; otherwise, create a new object if (isset($object['id']) === true) { return $mapper->updateFromArray($object['id'], $object, $updateVersion); diff --git a/lib/Service/ValidationService.php b/lib/Service/ValidationService.php index fd58b2b3..b3f974d0 100644 --- a/lib/Service/ValidationService.php +++ b/lib/Service/ValidationService.php @@ -3,9 +3,14 @@ namespace OCA\OpenCatalogi\Service; use OCA\OpenCatalogi\Db\CatalogMapper; +use OCA\OpenCatalogi\Db\Publication; +use OCA\OpenCatalogi\Db\PublicationType; use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\IAppConfig; +use OCP\IURLGenerator; +use Opis\JsonSchema\Errors\ErrorFormatter; +use Opis\JsonSchema\Validator; /** * Class ValidationService @@ -14,107 +19,42 @@ */ class ValidationService { - /** - * @var string The name of the application. - */ - private string $appName; - - /** - * @var array The current MongoDB Config. - */ - private array $mongodbConfig; - /** - * ValidationService constructor. - * - * @param IAppConfig $config The application config - * @param CatalogMapper $catalogMapper The catalog mapper. - * @param ObjectService $objectService The object service. - */ public function __construct( - private readonly IAppConfig $config, - private readonly CatalogMapper $catalogMapper, private readonly ObjectService $objectService, - ) { - $this->appName = 'opencatalogi'; - - // Initialize MongoDB configuration - $this->mongodbConfig = [ - 'base_uri' => $this->config->getValueString(app: $this->appName, key: 'mongodbLocation'), - 'headers' => ['api-key' => $this->config->getValueString(app: $this->appName, key: 'mongodbKey')], - 'mongodbCluster' => $this->config->getValueString(app: $this->appName, key:'mongodbCluster') - ]; - } - - /** - * Get the MongoDB configuration. - * - * @return array The mongodb config. - */ - public function getMongodbConfig(): array + private readonly IURLGenerator $urlGenerator, + ) { - return $this->mongodbConfig; } /** - * Fetches a catalog from either the local database or mongodb + * Validate a publication to the definition defined in the PublicationType. * - * @param string $id The id of the catalog to be fetched. - * @return array The JSON Serialised catalog. + * @param Publication $publication The publication to validate. + * @return Publication The validated publication. * - * @throws OCSNotFoundException If the catalog is not found. - * @throws \GuzzleHttp\Exception\GuzzleException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface */ - public function getCatalog (string $id): array + public function validatePublication(array $publication): array { - // Check if MongoDB storage is enabled - if ($this->config->hasKey(app: $this->appName, key: 'mongoStorage') !== false - && $this->config->getValueString(app: $this->appName, key: 'mongoStorage') === '1' - ) { - $filter = ['id' => $id, '_schema' => 'catalog']; + $publicationTypeId = $publication['publicationType']; + $publicationType = $this->objectService->getObject(objectType: 'publicationType', id: $publicationTypeId); - try { - return $this->objectService->findObject(filters: $filter, config: $this->getMongodbConfig()); - } catch (OCSNotFoundException $exception) { - throw new OCSNotFoundException(message: 'Catalog not found for id: ' . $id); - } - } - - // If MongoDB storage is not enabled, fetch from local database - return $this->catalogMapper->find(id: $id)->jsonSerialize(); - } + $publicationType = (new PublicationType())->hydrate($publicationType); - /** - * Validates a publication against the rules set for the publication. - * - * @param array $publication The publication to be validated. - * @return array The publication after it has been validated. - * - * @throws OCSBadRequestException Thrown if the object does not validate - * @throws OCSNotFoundException Thrown if the catalog is not found - */ - public function validatePublication(array $publication): array - { - // Check for required fields - $requiredFields = ['catalogi', 'publicationType']; - foreach ($requiredFields as $field) { - if (isset($publication[$field]) === false) { - throw new OCSBadRequestException(message: $field . ' is required but not given.'); - } - } + $validator = new Validator(); + $validator->setMaxErrors(100); - $catalog = $publication['catalogi']; - $publicationType = $publication['publicationType']; + $result = $validator->validate(data: (object) json_decode(json_encode($publication['data'])), schema: $publicationType->getSchema($this->urlGenerator)); - try { - $catalog = $this->getCatalog($catalog); - } catch (OCSNotFoundException $exception) { - throw new OCSNotFoundException(message: $exception->getMessage()); - } + $publication['validation'] = []; - // Check if the given publicationType is present in the catalog - if (in_array(needle: $publicationType, haystack: $catalog['publicationType']) === false) { - throw new OCSBadRequestException(message: 'Given publicationType object not present in catalog'); + if($result->hasError()) { + $errors = (new ErrorFormatter())->format($result->error()); + $publication['validation'] = $errors; } return $publication; From 0fed242f95897a7bdf8b76c14ef8b9c5846d2363 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Wed, 6 Nov 2024 14:36:58 +0100 Subject: [PATCH 2/3] Added docblock --- lib/Db/PublicationType.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/Db/PublicationType.php b/lib/Db/PublicationType.php index 36e409d9..b7a5a307 100644 --- a/lib/Db/PublicationType.php +++ b/lib/Db/PublicationType.php @@ -124,11 +124,18 @@ public function jsonSerialize(): array return $array; } + /** + * Generate a JSON-Schema definition for the data field of a publication. + * + * @param IURLGenerator $urlGenerator An URL generator to generate the identifier of the schema. + * + * @return object The JSON-Schema object defining the data field of a publication. + */ public function getSchema(IURLGenerator $urlGenerator): object { $schema = []; $schema['$schema'] = 'https://json-schema.org/draft/2020-12/schema'; - $schema['$id'] = $urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('openregister.Schemas.show', ['id' => $this->getUuid()])); + $schema['$id'] = $urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('opencatalogi.publication_types.show', ['id' => $this->getUuid()])); $schema['type'] = 'object'; $schema['required'] = []; $schema['properties'] = []; From 95542b2de010a6a1426cc66b4a8d4e52111859f7 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Thu, 7 Nov 2024 16:15:58 +0100 Subject: [PATCH 3/3] Fix PR comments --- lib/Db/PublicationType.php | 2 +- lib/Service/ObjectService.php | 2 +- lib/Service/ValidationService.php | 14 +++++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/Db/PublicationType.php b/lib/Db/PublicationType.php index b7a5a307..68b2e6e7 100644 --- a/lib/Db/PublicationType.php +++ b/lib/Db/PublicationType.php @@ -140,7 +140,7 @@ public function getSchema(IURLGenerator $urlGenerator): object $schema['required'] = []; $schema['properties'] = []; - foreach($this->getProperties() as $name => $property) { + foreach ($this->getProperties() as $name => $property) { if ($property['required'] === true) { $schema['required'][] = $name; } diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index b8084d7a..9a5e022f 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -307,7 +307,7 @@ public function saveObject(string $objectType, array $object, bool $updateVersio // Get the appropriate mapper for the object type $mapper = $this->getMapper($objectType); - if($objectType === 'publication') { + if ($objectType === 'publication') { $object = $this->validationService->validatePublication($object); } diff --git a/lib/Service/ValidationService.php b/lib/Service/ValidationService.php index b3f974d0..dc863d36 100644 --- a/lib/Service/ValidationService.php +++ b/lib/Service/ValidationService.php @@ -5,12 +5,16 @@ use OCA\OpenCatalogi\Db\CatalogMapper; use OCA\OpenCatalogi\Db\Publication; use OCA\OpenCatalogi\Db\PublicationType; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\IAppConfig; use OCP\IURLGenerator; use Opis\JsonSchema\Errors\ErrorFormatter; use Opis\JsonSchema\Validator; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; /** * Class ValidationService @@ -33,10 +37,10 @@ public function __construct( * @param Publication $publication The publication to validate. * @return Publication The validated publication. * - * @throws \OCP\AppFramework\Db\DoesNotExistException - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException - * @throws \Psr\Container\ContainerExceptionInterface - * @throws \Psr\Container\NotFoundExceptionInterface + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ public function validatePublication(array $publication): array { @@ -52,7 +56,7 @@ public function validatePublication(array $publication): array $publication['validation'] = []; - if($result->hasError()) { + if ($result->hasError()) { $errors = (new ErrorFormatter())->format($result->error()); $publication['validation'] = $errors; }