From a303c3b25ee3ebc4a5d4017588b24e1b73a90f6c Mon Sep 17 00:00:00 2001 From: orakili Date: Tue, 13 Feb 2024 03:46:52 +0000 Subject: [PATCH 01/73] chore: add json schema validator Refs: RW-831 --- composer.json | 1 + composer.lock | 200 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 196 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index b8611929b..2d50328c7 100644 --- a/composer.json +++ b/composer.json @@ -64,6 +64,7 @@ "league/commonmark": "^2.2", "league/html-to-markdown": "^5.0", "lolli42/finediff": "^1.0", + "opis/json-schema": "^2.2", "orakili/composer-drupal-info-file-patch-helper": "^1", "pelago/emogrifier": "^7.0", "reliefweb/api-indexer": "^v2.5.0", diff --git a/composer.lock b/composer.lock index 12b307418..dffab0122 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": "c8ed7d80cac97502630271c339665816", + "content-hash": "8594d2eee543181c8b21075eed485b12", "packages": [ { "name": "asm89/stack-cors", @@ -3691,7 +3691,7 @@ "shasum": "fc8ea60619b6b4682bade340e13fb4565d3a7e0c" }, "require": { - "drupal/core": "^9.1 || ^10" + "drupal/core": "^10" }, "type": "drupal-module", "extra": { @@ -3890,7 +3890,7 @@ "shasum": "c25246747dac4372c7d5a5a5fd0f276d9e468eff" }, "require": { - "drupal/core": "^9.3 || ^10", + "drupal/core": "^10", "drupal/mailsystem": "^4" }, "require-dev": { @@ -4592,7 +4592,7 @@ "shasum": "77906ae731878b68a181f82b073617b798e5f110" }, "require": { - "drupal/core": "^9.3 || ^10", + "drupal/core": "^10", "enshrined/svg-sanitize": ">=0.15 <1.0" }, "type": "drupal-module", @@ -4740,7 +4740,7 @@ "shasum": "9ea9eee91cf75f21fcc939704baa6a7ec10d7748" }, "require": { - "drupal/core": "^8.9 || ^9 || ^10" + "drupal/core": "^10" }, "type": "drupal-module", "extra": { @@ -7920,6 +7920,196 @@ }, "time": "2023-11-21T22:12:22+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": "orakili/composer-drupal-info-file-patch-helper", "version": "1.0.1", From 23de2f705b0ecfd650a739127d5362a874ae353b Mon Sep 17 00:00:00 2001 From: orakili Date: Tue, 13 Feb 2024 03:50:23 +0000 Subject: [PATCH 02/73] fix: only save as embargoed when attempting to publish a report with an embargo date Refs: RW-831 --- html/modules/custom/reliefweb_entities/src/Entity/Report.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/html/modules/custom/reliefweb_entities/src/Entity/Report.php b/html/modules/custom/reliefweb_entities/src/Entity/Report.php index 5e115009e..b04a6dfa5 100644 --- a/html/modules/custom/reliefweb_entities/src/Entity/Report.php +++ b/html/modules/custom/reliefweb_entities/src/Entity/Report.php @@ -235,14 +235,15 @@ public function preSave(EntityStorageInterface $storage) { } // Change the status to `embargoed` if there is an embargo date. - if (!empty($this->field_embargo_date->value) && $this->getModerationStatus() !== 'draft') { + $embargo_statuses = ['embargoed', 'to-review', 'published']; + if (!empty($this->field_embargo_date->value) && in_array($this->getModerationStatus(), $embargo_statuses)) { $this->setModerationStatus('embargoed'); $message = strtr('Embargoed (to be automatically published on @date).', [ '@date' => DateHelper::format($this->field_embargo_date->value, 'custom', 'd M Y H:i e'), ]); - $log = trim($this->getRevisionLogMessage()); + $log = trim($this->getRevisionLogMessage() ?? ''); $log = $message . (!empty($log) ? "\n" . $log : ''); $this->setRevisionLogMessage($log); } From 46b9f545659a9222b6c5145d9116b2c13d5deeec Mon Sep 17 00:00:00 2001 From: orakili Date: Tue, 13 Feb 2024 03:52:49 +0000 Subject: [PATCH 03/73] chore: skip text cleaning when empty string Refs: RW-831 --- .../custom/reliefweb_utility/src/Helpers/TextHelper.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/html/modules/custom/reliefweb_utility/src/Helpers/TextHelper.php b/html/modules/custom/reliefweb_utility/src/Helpers/TextHelper.php index a2db86fd3..5053814fb 100644 --- a/html/modules/custom/reliefweb_utility/src/Helpers/TextHelper.php +++ b/html/modules/custom/reliefweb_utility/src/Helpers/TextHelper.php @@ -30,6 +30,9 @@ class TextHelper { * Cleaned text. */ public static function cleanText($text, array $options = []) { + if ($text === '') { + return ''; + } $patterns = ['/[\t]/u', '/[\xA0]+/u', '/[\x00-\x09\x0B-\x1F\x7F]/u']; $replacements = [' ', ' ', '']; // Replace (consecutive) line breaks with a single space. From 1a262efd1f2f4c9be8a8131edfe307096982183d Mon Sep 17 00:00:00 2001 From: orakili Date: Tue, 13 Feb 2024 04:42:21 +0000 Subject: [PATCH 04/73] feat: add pending and refused statuses to reports Refs: RW-831 --- .../src/Services/ReportModeration.php | 11 ++++++++++- .../rw-moderation-status/rw-moderation-status.css | 5 ++++- .../custom/common_design_subtheme/css/brand.css | 3 +++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php b/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php index 50d214e32..71b95b2e5 100644 --- a/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php +++ b/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php @@ -203,8 +203,10 @@ public function getStatuses() { 'to-review' => $this->t('To review'), 'published' => $this->t('Published'), 'embargoed' => $this->t('Embargoed'), - 'archive' => $this->t('Archived'), 'reference' => $this->t('Reference'), + 'pending' => $this->t('Pending'), + 'refused' => $this->t('Refused'), + 'archive' => $this->t('Archived'), ]; } @@ -214,6 +216,7 @@ public function getStatuses() { public function getFilterDefaultStatuses() { $statuses = $this->getFilterStatuses(); unset($statuses['archive']); + unset($statuses['refused']); return array_keys($statuses); } @@ -237,6 +240,12 @@ public function getEntityFormSubmitButtons($status, EntityModeratedInterface $en 'reference' => [ '#value' => $this->t('Reference'), ], + 'pending' => [ + '#value' => $this->t('Pending'), + ], + 'refused' => [ + '#value' => $this->t('Refused'), + ], ]; // @todo replace with permission. diff --git a/html/themes/custom/common_design_subtheme/components/rw-moderation-status/rw-moderation-status.css b/html/themes/custom/common_design_subtheme/components/rw-moderation-status/rw-moderation-status.css index 7a62815a3..756a73dc6 100644 --- a/html/themes/custom/common_design_subtheme/components/rw-moderation-status/rw-moderation-status.css +++ b/html/themes/custom/common_design_subtheme/components/rw-moderation-status/rw-moderation-status.css @@ -48,11 +48,14 @@ background: var(--cd-reliefweb-yellow--bg); } [data-moderation-status="pending"], -[data-moderation-status="embargoed"], [data-moderation-status="external-archive"] { border-color: var(--cd-reliefweb-cyan); background-color: var(--cd-reliefweb-cyan--bg); } +[data-moderation-status="embargoed"] { + border-color: var(--cd-reliefweb-purple--light); + background-color: var(--cd-reliefweb-purple--light--bg); +} .rw-moderation-status[data-moderation-status] { display: block; diff --git a/html/themes/custom/common_design_subtheme/css/brand.css b/html/themes/custom/common_design_subtheme/css/brand.css index 42473721b..66a104c0e 100644 --- a/html/themes/custom/common_design_subtheme/css/brand.css +++ b/html/themes/custom/common_design_subtheme/css/brand.css @@ -58,6 +58,9 @@ /* Purple */ --cd-reliefweb-purple: #9509bb; --cd-reliefweb-purple--bg: rgba(149, 9, 187, 0.2); + /* Light purple */ + --cd-reliefweb-purple--light: #9FA8DA; + --cd-reliefweb-purple--light--bg: rgb(159, 168, 218, 0.2); /* Grey */ --cd-reliefweb-grey: #4b4b4b; --cd-reliefweb-grey--bg: rgba(75, 75, 75, 0.2); From 55615315cd26be43d30ec573a9dbf808bec62d43 Mon Sep 17 00:00:00 2001 From: orakili Date: Tue, 13 Feb 2024 04:44:10 +0000 Subject: [PATCH 05/73] feat: first prototype of the POST API Refs: RW-831 --- .../custom/reliefweb_post_api/README.md | 20 + .../reliefweb_post_api/drush.services.yml | 6 + .../reliefweb_post_api.info.yml | 7 + .../reliefweb_post_api.routing.yml | 6 + .../reliefweb_post_api.services.yml | 7 + .../reliefweb_post_api/schemas/report.json | 245 +++ .../src/Attribute/ContentProcessor.php | 39 + .../src/Commands/ReliefWebPostApiCommands.php | 103 ++ .../src/Controller/ReliefWebPostApi.php | 154 ++ .../src/Entity/Provider.php | 67 + .../src/Entity/ProviderInterface.php | 63 + .../src/Plugin/ContentProcessorException.php | 12 + .../src/Plugin/ContentProcessorPluginBase.php | 866 ++++++++++ .../ContentProcessorPluginInterface.php | 470 +++++ .../Plugin/ContentProcessorPluginManager.php | 79 + ...ContentProcessorPluginManagerInterface.php | 31 + .../ContentProcessor/Report.php | 106 ++ .../src/Services/ProviderManager.php | 44 + .../src/Services/ProviderManagerInterface.php | 25 + .../tests/data/data-invalid.json | 31 + .../tests/data/data-report.json | 35 + .../tests/data/test-schema-02.json | 23 + .../reliefweb_post_api/tests/data/test1.pdf | Bin 0 -> 12112 bytes .../reliefweb_post_api/tests/data/test1.png | Bin 0 -> 8734 bytes .../reliefweb_post_api/tests/data/test2.pdf | Bin 0 -> 12295 bytes .../reliefweb_post_api/tests/data/test2.png | Bin 0 -> 10573 bytes .../reliefweb_post_api/tests/data/test3.pdf | Bin 0 -> 12331 bytes .../reliefweb_post_api/tests/data/test3.png | Bin 0 -> 5580 bytes .../reliefweb_post_api/tests/data/test4.pdf | Bin 0 -> 12180 bytes .../Attribute/ContentProcessorTest.php | 33 + .../Commands/ReliefWebPostApiCommandsTest.php | 199 +++ .../Controller/ReliefWebPostApiTest.php | 393 +++++ .../src/ExistingSite/Entity/ProviderTest.php | 127 ++ .../Plugin/ContentProcessorPluginBaseTest.php | 1510 +++++++++++++++++ .../ContentProcessorPluginManagerTest.php | 77 + .../ContentProcessor/ReportTest.php | 113 ++ .../Services/ProviderManagerTest.php | 43 + 37 files changed, 4934 insertions(+) create mode 100644 html/modules/custom/reliefweb_post_api/README.md create mode 100644 html/modules/custom/reliefweb_post_api/drush.services.yml create mode 100644 html/modules/custom/reliefweb_post_api/reliefweb_post_api.info.yml create mode 100644 html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml create mode 100644 html/modules/custom/reliefweb_post_api/reliefweb_post_api.services.yml create mode 100644 html/modules/custom/reliefweb_post_api/schemas/report.json create mode 100644 html/modules/custom/reliefweb_post_api/src/Attribute/ContentProcessor.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Commands/ReliefWebPostApiCommands.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Entity/Provider.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorException.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginInterface.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManager.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManagerInterface.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Services/ProviderManager.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Services/ProviderManagerInterface.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/data-invalid.json create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/data-report.json create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/test-schema-02.json create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/test1.pdf create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/test1.png create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/test2.pdf create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/test2.png create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/test3.pdf create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/test3.png create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/test4.pdf create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Attribute/ContentProcessorTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Commands/ReliefWebPostApiCommandsTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginManagerTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Services/ProviderManagerTest.php diff --git a/html/modules/custom/reliefweb_post_api/README.md b/html/modules/custom/reliefweb_post_api/README.md new file mode 100644 index 000000000..662ade960 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/README.md @@ -0,0 +1,20 @@ +ReliefWeb POST API +================== + + +This module provides a simple POST API to allow partners to submit content. + +TODO +---- + +- [ ] Better authentication +- [ ] Associated user with API key? +- [ ] Use different users to create content instead of system user? +- [ ] Notify poster when content is created? +- [ ] How to give feedback? + --> Generate UUID, return it when submitting? + --> Add other endpoint to query status (404, refused, pending, published?) +- [ ] Limit the content types the provider can post +- [ ] Limit the fields the provider can populate +- [ ] Validate report original publication date so it cannot be in the future? +- [ ] Use a custom fiedable entity for the providers? diff --git a/html/modules/custom/reliefweb_post_api/drush.services.yml b/html/modules/custom/reliefweb_post_api/drush.services.yml new file mode 100644 index 000000000..9d195bda0 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/drush.services.yml @@ -0,0 +1,6 @@ +services: + reliefweb_post_api.commands: + class: \Drupal\reliefweb_post_api\Commands\ReliefWebPostApiCommands + arguments: ['@queue', '@plugin.manager.reliefweb_post_api.content_processor'] + tags: + - { name: drush.command } diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.info.yml b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.info.yml new file mode 100644 index 000000000..5e853b6e6 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.info.yml @@ -0,0 +1,7 @@ +type: module +name: ReliefWeb POST API +description: 'Provides as simple POST API.' +package: reliefweb +core_version_requirement: ^10 +dependencies: + - drupal:reliefweb_utility diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml new file mode 100644 index 000000000..576ab4ac1 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml @@ -0,0 +1,6 @@ +example: + path: '/post/{bundle}' + defaults: + _controller: '\Drupal\reliefweb_post_api\Controller\ReliefWebPostApi::postContent' + requirements: + _access: 'TRUE' diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.services.yml b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.services.yml new file mode 100644 index 000000000..aae627b09 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.services.yml @@ -0,0 +1,7 @@ +services: + plugin.manager.reliefweb_post_api.content_processor: + class: Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManager + parent: default_plugin_manager + reliefweb_post_api.provider.manager: + class: Drupal\reliefweb_post_api\Services\ProviderManager + arguments: [] diff --git a/html/modules/custom/reliefweb_post_api/schemas/report.json b/html/modules/custom/reliefweb_post_api/schemas/report.json new file mode 100644 index 000000000..064fbbc42 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/schemas/report.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://reliefweb.int/post-api-schemas/report.json", + "title": "ReliefWeb POST API schema - report resource", + "type": "object", + "properties": { + "url": { + "description": "Unique URL to identify the document. Use the original canonical of the document if available.", + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "title": { + "description": "Document title.", + "type": "string", + "minLength": 10, + "maxLength": 255, + "allOf": [ + { + "description": "Must contain letters (any language).", + "pattern": "\\p{L}+" + }, + { + "description": "No control characters or separators except for spaces.", + "pattern": "^([^\\p{Z}\\p{C}]|[ ])+$" + } + ], + "not": { + "description": "No leading, trailing or consecutive spaces.", + "pattern": "(?:^[ ]|[ ]$|[ ]{2,})" + } + }, + "source": { + "description": "Document source(s) as a list of IDs from https://api.reliefweb.int/v1/sources.", + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 30 + }, + "country": { + "description": "Document country(ies) as a list of IDs from https://api.reliefweb.int/v1/countries. The first country in the list is considered the primary country, meaning the most relevant to the content of the document.", + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 300 + }, + "format": { + "description": "Document format as a single ID from https://api.reliefweb.int/v1/references/content-formats.", + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 1 + }, + "language": { + "description": "Document language(s) as a list of IDs from https://api.reliefweb.int/v1/references/languages.", + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 10 + }, + "published": { + "description": "Original publication date (ISO 8601) of the document.", + "type": "string", + "format": "date-time" + }, + "body": { + "description": "Document content in markdown or html (supported tags:


paragraph 1 bold

paragraph 2 link

", + "embargoed": "2024-06-06T07:00:00+00:00", + "file": [ + { + "url": "https://test.test/test1.pdf", + "checksum": "42fffa6c0e84ebab15f0f1ead8319efc448d0d883a0b492528997b4f17989755", + "description": "First attachment", + "language": "en" + }, + { + "url": "https://test.test/test2.pdf", + "checksum": "0b071d7d2a430d5f4ecbd919995a8a6e18a248bccbad4dc910c9dd1e906a2e79", + "description": "Second attachment", + "language": "fr" + } + ], + "image": { + "url": "https://test.test/test1.png", + "checksum": "c24863f6aa2065dd5071a831ee78439260ea72983799dd3e88fbfb4aa08fdc18", + "description": "Test image", + "copyright": "Test NGO" + }, + "disaster": [51754], + "disaster_type": [4628], + "theme": [4587, 4595, 4603], + "origin": "https://test.test/test/3" +} diff --git a/html/modules/custom/reliefweb_post_api/tests/data/test-schema-02.json b/html/modules/custom/reliefweb_post_api/tests/data/test-schema-02.json new file mode 100644 index 000000000..ec481880a --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/data/test-schema-02.json @@ -0,0 +1,23 @@ +{ + "url": "https://test.test/test/2", + "title": "This a test document - DOC2 - attachment", + "source": [1503], + "country": [13, 14], + "format": [10], + "language": [267, 268], + "published": "2024-06-06T01:00:00+00:00", + "body": "This is the **body** text in markdown format.\n\nIt has some paragraphs \nand a line break.

TITLE

pouet parag

  • dsdf
  • sdfgsdgf

sdfgs Bidule

", + "embargoed": "2024-06-06T07:00:00+00:00", + "file": [ + { + "url": "https://reliefweb.int/attachments/a2e0b418-1aef-4e63-9cd2-6277a29d6ccf/test-01.pdf", + "checksum": "0fc1dd7bae056baf7fff962117363e1f", + "description": "First attachment", + "language": "en" + } + ], + "disaster": [51754], + "disaster_type": [4628], + "theme": [4587, 4595, 4603], + "origin": "https://test.test/test/2" +} diff --git a/html/modules/custom/reliefweb_post_api/tests/data/test1.pdf b/html/modules/custom/reliefweb_post_api/tests/data/test1.pdf new file mode 100644 index 0000000000000000000000000000000000000000..72460f3d5811188752b9e8ecdc49c488a25ef5e8 GIT binary patch literal 12112 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 XC-zBjyq9MDw+=0JUA5E`m%aZB9%^gh literal 0 HcmV?d00001 diff --git a/html/modules/custom/reliefweb_post_api/tests/data/test2.pdf b/html/modules/custom/reliefweb_post_api/tests/data/test2.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bda8c4207086b978a703eb85e6aeb5050183e8ea GIT binary patch literal 12295 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 literal 0 HcmV?d00001 diff --git a/html/modules/custom/reliefweb_post_api/tests/data/test2.png b/html/modules/custom/reliefweb_post_api/tests/data/test2.png new file mode 100644 index 0000000000000000000000000000000000000000..bb2047b7ea8eb6603e1e06d0e20903c001eb00fd GIT binary patch literal 10573 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 K<@p8M8~+D7sO+== literal 0 HcmV?d00001 diff --git a/html/modules/custom/reliefweb_post_api/tests/data/test3.pdf b/html/modules/custom/reliefweb_post_api/tests/data/test3.pdf new file mode 100644 index 0000000000000000000000000000000000000000..33ec729014bcdfb59f2ffaf31f988e2ee58ae911 GIT binary patch literal 12331 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 G@&5p}qLlvt literal 0 HcmV?d00001 diff --git a/html/modules/custom/reliefweb_post_api/tests/data/test3.png b/html/modules/custom/reliefweb_post_api/tests/data/test3.png new file mode 100644 index 0000000000000000000000000000000000000000..f5a612e36d88220e8e8e4ba35e251007a558c69a GIT binary patch literal 5580 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 Z{e1kLrm>UlwX{EB4fRZQ3$z`t{2L8db&~)9 literal 0 HcmV?d00001 diff --git a/html/modules/custom/reliefweb_post_api/tests/data/test4.pdf b/html/modules/custom/reliefweb_post_api/tests/data/test4.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cf5db8f5b348c0a88476b0f3f9a4ae604a200566 GIT binary patch literal 12180 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 TN0fj-ZVn(CHMNA2B-;N2J_0C0 literal 0 HcmV?d00001 diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Attribute/ContentProcessorTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Attribute/ContentProcessorTest.php new file mode 100644 index 000000000..8b078c56a --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Attribute/ContentProcessorTest.php @@ -0,0 +1,33 @@ +assertInstanceOf(ContentProcessor::class, $attribute); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Commands/ReliefWebPostApiCommandsTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Commands/ReliefWebPostApiCommandsTest.php new file mode 100644 index 000000000..1e52bd8d0 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Commands/ReliefWebPostApiCommandsTest.php @@ -0,0 +1,199 @@ +logger = new BufferingLogger(); + } + + /** + * @covers ::__construct + */ + public function testConstructor(): void { + $this->assertInstanceOf(ReliefWebPostApiCommands::class, $this->createTestCommandHandler()); + } + + /** + * @covers ::process + */ + public function testProcessEmptyQueue(): void { + $queue = $this->createConfiguredMock(QueueInterface::class, [ + 'claimItem' => FALSE, + ]); + + $queue_factory = $this->createConfiguredMock(QueueFactory::class, [ + 'get' => $queue, + ]); + + $handler = $this->createTestCommandHandler([ + 'queue' => $queue_factory, + ]); + + $handler->process(['limit' => 1]); + $this->assertSame([ + ['info', 'Processed 0 queued submissions.', []], + ], $this->logger->cleanLogs()); + } + + /** + * @covers ::process + */ + public function testProcessUnsupportedBundle(): void { + $item = new \stdClass(); + $item->data = ['bundle' => 'unsupported']; + $item->item_id = 'abc'; + + $queue = $this->createConfiguredMock(QueueInterface::class, [ + 'claimItem' => $item, + ]); + + $queue_factory = $this->createConfiguredMock(QueueFactory::class, [ + 'get' => $queue, + ]); + + $handler = $this->createTestCommandHandler([ + 'queue' => $queue_factory, + ]); + + $handler->process(['limit' => 1]); + $this->assertSame([ + ['error', 'Unsupported bundle: unsupported, skipping item: abc.', []], + ['info', 'Processed 1 queued submissions.', []], + ], $this->logger->cleanLogs()); + } + + /** + * @covers ::process + */ + public function testProcessProcessException(): void { + $item = new \stdClass(); + $item->data = ['bundle' => 'report']; + $item->item_id = 'abc'; + + $queue = $this->createConfiguredMock(QueueInterface::class, [ + 'claimItem' => $item, + ]); + + $queue_factory = $this->createConfiguredMock(QueueFactory::class, [ + 'get' => $queue, + ]); + + $plugin = $this->createMock(ContentProcessorPluginInterface::class); + $plugin->expects($this->any()) + ->method('process') + ->willThrowException(new \Exception('test exception')); + + $plugin_manager = $this->createConfiguredMock(ContentProcessorPluginManagerInterface::class, [ + 'getPluginByBundle' => $plugin, + ]); + + $handler = $this->createTestCommandHandler([ + 'queue' => $queue_factory, + 'plugin.manager.reliefweb_post_api.content_processor' => $plugin_manager, + ]); + + $handler->process(['limit' => 1]); + $this->assertSame([ + ['info', 'Processing queued report: abc.', []], + ['error', 'Error processing report abc: test exception.', []], + ['info', 'Processed 1 queued submissions.', []], + ], $this->logger->cleanLogs()); + } + + /** + * @covers ::process + */ + public function testProcess(): void { + $item = new \stdClass(); + $item->data = ['bundle' => 'report']; + $item->item_id = 'abc'; + + $queue = $this->createConfiguredMock(QueueInterface::class, [ + 'claimItem' => $item, + ]); + + $queue_factory = $this->createConfiguredMock(QueueFactory::class, [ + 'get' => $queue, + ]); + + $entity = $this->createConfiguredMock(Node::class, [ + 'id' => 123, + 'getRevisionLogMessage' => 'Automatic creation from POST API.', + ]); + + $plugin = $this->createConfiguredMock(ContentProcessorPluginInterface::class, [ + 'process' => $entity, + ]); + + $plugin_manager = $this->createConfiguredMock(ContentProcessorPluginManagerInterface::class, [ + 'getPluginByBundle' => $plugin, + ]); + + $handler = $this->createTestCommandHandler([ + 'queue' => $queue_factory, + 'plugin.manager.reliefweb_post_api.content_processor' => $plugin_manager, + ]); + + $handler->process(['limit' => 1]); + $this->assertSame([ + ['info', 'Processing queued report: abc.', []], + ['info', 'Successfully created report entity with id 123.', []], + ['info', 'Processed 1 queued submissions.', []], + ], $this->logger->cleanLogs()); + } + + /** + * Create a test drush command handler. + * + * @param array $services + * Services. + * + * @return \Drupal\reliefweb_post_api\Commands\ReliefWebPostApiCommands + * The drush command handler. + */ + protected function createTestCommandHandler(array $services = []): ReliefWebPostApiCommands { + $container = \Drupal::getContainer(); + + $services = [ + $services['queue'] ?? $container->get('queue'), + $services['plugin.manager.reliefweb_post_api.content_processor'] ?? $container->get('plugin.manager.reliefweb_post_api.content_processor'), + ]; + + $handler = new ReliefWebPostApiCommands(...$services); + $handler->setLogger($this->logger); + return $handler; + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php new file mode 100644 index 000000000..519295057 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php @@ -0,0 +1,393 @@ + 'test-provider', + 'key' => 'test-provider-key', + 'sources' => [1503], + 'url_pattern' => '#^https://test.test/#', + ]; + new Settings($settings); + } + + /** + * @covers ::__construct + */ + public function testContructor(): void { + $this->assertInstanceOf(ReliefWebPostApi::class, $this->createTestController()); + } + + /** + * @covers ::create + */ + public function testCreate(): void { + $controller = ReliefWebPostApi::create(\Drupal::getContainer()); + $this->assertInstanceOf(ReliefWebPostApi::class, $controller); + } + + /** + * @covers ::postContent + */ + public function testPostContentUnknownException(): void { + $request_stack = $this->createMock(RequestStack::class); + $request_stack->expects($this->any()) + ->method('getCurrentRequest') + ->willThrowException(new \Exception('test exception')); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(500, $response->getStatusCode()); + $this->assertStringContainsString('test exception', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentMethodNotAllowed(): void { + $request = $this->createMockRequest(methods: [ + 'getMethod' => 'GET', + ]); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(405, $response->getStatusCode()); + $this->assertStringContainsString('Unsupported method.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentInvalidProvider(): void { + $request = $this->createMockRequest([ + 'X-RW-POST-API-PROVIDER' => 'invalid', + ]); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(403, $response->getStatusCode()); + $this->assertStringContainsString('Invalid provider.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentInvalidApiKey(): void { + $request = $this->createMockRequest([ + 'X-RW-POST-API-KEY' => 'invalid', + ]); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(403, $response->getStatusCode()); + $this->assertStringContainsString('Invalid API key.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentInvalidEndpoint(): void { + $request = $this->createMockRequest(); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('test'); + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringContainsString('Invalid endpoint.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentInvalidContentFormat(): void { + $request = $this->createMockRequest(methods: [ + 'getContentTypeFormat' => 'invalid', + ]); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('Invalid content format.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentMissingRequestBody(): void { + $request = $this->createMockRequest(methods: [ + 'getContent' => NULL, + ]); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('Missing request body.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentInvalidRequestBody(): void { + $request = $this->createMockRequest(methods: [ + 'getContent' => ['test'], + ]); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('Invalid request body.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentInvalidJsonBody(): void { + $request = $this->createMockRequest(methods: [ + 'getContent' => '{json: invalid}', + ]); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('Invalid JSON body.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentInvalidData(): void { + $request = $this->createMockRequest(methods: [ + 'getContent' => '[]', + ]); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('Invalid data', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentQueueException(): void { + $request = $this->createMockRequest(); + + $request_stack = $this->createMockRequestStack($request); + + $queue_factory = $this->createMock(QueueFactory::class); + $queue_factory->expects($this->any()) + ->method('get') + ->willThrowException(new \Exception()); + + $controller = $this->createTestController([ + 'queue' => $queue_factory, + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(500, $response->getStatusCode()); + $this->assertStringContainsString('Internal server error.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContent(): void { + $request = $this->createMockRequest(); + + $request_stack = $this->createMockRequestStack($request); + + $queue = $this->createConfiguredMock(QueueInterface::class, [ + 'createItem' => TRUE, + ]); + + $queue_factory = $this->createConfiguredMock(QueueFactory::class, [ + 'get' => $queue, + ]); + + $controller = $this->createTestController([ + 'queue' => $queue_factory, + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('report'); + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('Document queued for processing.', $response->getContent()); + } + + /** + * Create a mock request. + * + * @param array $headers + * Headers. + * @param array $methods + * Methods for the mock request. + * + * @return \Symfony\Component\HttpFoundation\Request + * The mock request. + */ + protected function createMockRequest(array $headers = [], array $methods = []): Request { + $headers += [ + 'X-RW-POST-API-PROVIDER' => 'test-provider', + 'X-RW-POST-API-KEY' => 'test-provider-key', + ]; + + $header_map = []; + foreach ($headers as $key => $value) { + $header_map[] = [$key, '', $value]; + } + + $header_bag = $this->createMock(HeaderBag::class); + $header_bag->expects($this->any()) + ->method('get') + ->willReturnMap($header_map); + + $methods += [ + 'getMethod' => 'POST', + 'getContentTypeFormat' => 'json', + 'getContent' => $this->getPostApiData(), + ]; + + $request = $this->createConfiguredMock(Request::class, $methods); + $request->headers = $header_bag; + + return $request; + } + + /** + * Create a mock request stack. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * + * @return \Symfony\Component\HttpFoundation\RequestStack + * The mock request stack. + */ + protected function createMockRequestStack(Request $request): RequestStack { + return $this->createConfiguredMock(RequestStack::class, [ + 'getCurrentRequest' => $request, + ]); + } + + /** + * Create a test controller. + * + * @param array $services + * List of services. + * + * @return \Drupal\reliefweb_post_api\Controller\ReliefWebPostApi + * The test controller. + */ + protected function createTestController(array $services = []): ReliefWebPostApi { + $container = \drupal::getContainer(); + + $services = [ + $services['request_stack'] ?? $container->get('request_stack'), + $services['queue'] ?? $container->get('queue'), + $services['plugin.manager.reliefweb_post_api.content_processor'] ?? $container->get('plugin.manager.reliefweb_post_api.content_processor'), + $services['reliefweb_post_api.provider.manager'] ?? $container->get('reliefweb_post_api.provider.manager'), + ]; + + return new ReliefWebPostApi(...$services); + } + + /** + * Get some POST API test data. + * + * @param string $bundle + * The bundle of the data. + * + * @return string + * The data as a JSON string. + */ + protected function getPostApiData(string $bundle = 'report'): string { + if (!isset($this->postApiData[$bundle])) { + $file = __DIR__ . '/../../../data/data-' . $bundle . '.json'; + $this->postApiData[$bundle] = file_get_contents($file); + } + return $this->postApiData[$bundle]; + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php new file mode 100644 index 000000000..3a40ac072 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php @@ -0,0 +1,127 @@ + 'test-provider', + 'key' => 'test-provider-key', + 'url_pattern' => '@^https://test.test/@', + 'notify' => ['test@test.test'], + 'sources' => [1503], + 'uid' => 12, + ]; + + /** + * @covers ::__construct + */ + public function testContructor(): void { + $provider = new Provider($this->data); + $this->assertInstanceOf(Provider::class, $provider); + } + + /** + * @covers ::id + */ + public function testId(): void { + $data = $this->data; + + $provider = new Provider($data); + $this->assertEquals($this->data['id'], $provider->id()); + + unset($data['id']); + $provider = new Provider($data); + $this->assertEquals('', $provider->id()); + } + + /** + * @covers ::getUrlPattern + */ + public function testGetUrlPattern(): void { + $data = $this->data; + + $provider = new Provider($data); + $this->assertEquals($this->data['url_pattern'], $provider->getUrlPattern()); + + unset($data['url_pattern']); + $provider = new Provider($data); + $this->assertEquals('#^https://.+$#', $provider->getUrlPattern()); + } + + /** + * @covers ::getEmailsToNotify + */ + public function testGetEmailsToNotify(): void { + $data = $this->data; + + $provider = new Provider($data); + $this->assertEquals($this->data['notify'], $provider->getEmailsToNotify()); + + unset($data['notify']); + $provider = new Provider($data); + $this->assertEquals([], $provider->getEmailsToNotify()); + } + + /** + * @covers ::getAllowedSources + */ + public function testGetAllowedSources(): void { + $data = $this->data; + + $provider = new Provider($data); + $this->assertEquals($this->data['sources'], $provider->getAllowedSources()); + + unset($data['sources']); + $provider = new Provider($data); + $this->assertEquals([], $provider->getAllowedSources()); + } + + /** + * @covers ::getUserId + */ + public function testGetUserId(): void { + $data = $this->data; + + $provider = new Provider($data); + $this->assertEquals($this->data['uid'], $provider->getUserId()); + + unset($data['uid']); + $provider = new Provider($data); + $this->assertEquals(2, $provider->getUserId()); + } + + /** + * @covers ::validateKey + */ + public function testValidateKey(): void { + $data = $this->data; + + $provider = new Provider($data); + $this->assertTrue($provider->validateKey($this->data['key'])); + $this->assertFalse($provider->validateKey('wrong')); + $this->assertFalse($provider->validateKey('')); + + unset($data['key']); + $provider = new Provider($data); + $this->assertFalse($provider->validateKey($this->data['key'])); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php new file mode 100644 index 000000000..6aa2bbad4 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php @@ -0,0 +1,1510 @@ + 'test-provider-key', + 'sources' => [123], + 'url_pattern' => '#^https://test.test/#', + ]; + $settings['reliefweb_post_api.providers']['test-provider-any'] = [ + 'key' => 'test-provider-any-key', + 'sources' => [], + 'url_pattern' => '', + ]; + new Settings($settings); + + $this->contentProcessorPluginManager = \Drupal::service('plugin.manager.reliefweb_post_api.content_processor'); + } + + /** + * @covers ::__construct + */ + public function testConstructor(): void { + $plugin = $this->createDummyPlugin(); + $this->assertInstanceOf(ContentProcessorPluginInterface::class, $plugin); + } + + /** + * @covers ::create + */ + public function testCreate(): void { + $plugin = $this->plugin::create(\Drupal::getContainer(), [], 'dummy', []); + $this->assertInstanceOf(ContentProcessorPluginInterface::class, $plugin); + } + + /** + * @covers ::getPluginLabel + */ + abstract public function testGetPluginLabel(): void; + + /** + * @covers ::getEntityType + */ + abstract public function testGetEntityType(): void; + + /** + * @covers ::getEntityBundle + */ + abstract public function testGetEntityBundle(): void; + + /** + * @covers ::getLogger + */ + public function testGetLogger(): void { + $this->assertInstanceOf(LoggerInterface::class, $this->plugin->getLogger()); + } + + /** + * @covers ::getSchemaValidator + */ + public function testGetSchemaValidator(): void { + $this->assertInstanceOf(Validator::class, $this->plugin->getSchemaValidator()); + } + + /** + * @covers ::getJsonSchema + */ + public function testGetJsonSchema(): void { + // Valid schema. + $schema = $this->plugin->getJsonSchema(); + $this->assertNotEmpty($schema); + } + + /** + * @covers ::getJsonSchema + */ + public function testGetJsonSchemaInvalid(): void { + $plugin = $this->createDummyPlugin(); + + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Missing dummy JSON schema'); + $plugin->getJsonSchema(); + } + + /** + * @covers ::getProvider + */ + public function testGetProvider(): void { + // Valid provider. + $provider = $this->plugin->getProvider('test-provider'); + $this->assertInstanceOf(ProviderInterface::class, $provider); + } + + /** + * @covers ::getProvider + */ + public function testGetProviderInvalid(): void { + // Invalid provider. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Invalid provider'); + $this->plugin->getProvider('unknown'); + } + + /** + * @covers ::validate + */ + public function testValidate(): void { + $data = $this->getPostApiData(); + + // Test valid data. + $this->plugin->validate(['source' => [123]] + $data); + $this->assertTrue(TRUE); + } + + /** + * @covers ::validate + */ + public function testValidateInvalidSchema(): void { + $data = $this->getPostApiData(); + + // Test invalid schema. + $this->expectException(ContentProcessorException::class); + $this->plugin->validate(['source' => ''] + $data); + } + + /** + * @covers ::validate + */ + public function testValidateInvalidSource(): void { + $data = $this->getPostApiData(); + + // Test invalid source. + $this->expectException(ContentProcessorException::class); + $this->plugin->validate(['source' => [456]] + $data); + + } + + /** + * @covers ::validate + */ + public function testValidateInvalidUrl(): void { + $data = $this->getPostApiData(); + + // Test invalid URL. + $this->expectException(ContentProcessorException::class); + $this->plugin->validate(['url' => ''] + $data); + } + + /** + * @covers ::validateSchema + */ + public function testValidateSchema(): void { + $data = $this->getPostApiData(); + + // Valid data. + $this->plugin->validateSchema($data); + $this->assertTrue(TRUE); + } + + /** + * @covers ::validateSchema + */ + public function testValidateSchemaInvalid(): void { + $data = $this->getPostApiData(); + + // Invalid data. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('must match the type: string'); + $this->plugin->validateSchema(['url' => FALSE] + $data); + } + + /** + * @covers ::validateSources + */ + public function testValidateSources(): void { + $data = $this->getPostApiData(); + + // Allowed source. + $this->plugin->validateSources(['source' => [123]] + $data); + $this->assertTrue(TRUE); + + // Any source allowed. + $this->plugin->validateSources([ + 'provider' => 'test-provider-any', + 'source' => ['789'], + ] + $data); + $this->assertTrue(TRUE); + } + + /** + * @covers ::validateSources + */ + public function testValidateSourcesMissingSource(): void { + $data = $this->getPostApiData(); + + // Missing source. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Unallowed source(s)'); + $this->plugin->validateSources(['source' => []] + $data); + } + + /** + * @covers ::validateSources + */ + public function testValidateSourcesUnallowedSource(): void { + $data = $this->getPostApiData(); + + // Unallowed source. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Unallowed source(s)'); + $this->plugin->validateSources(['source' => [456]] + $data); + } + + /** + * @covers ::validateSources + */ + public function testValidateSourcesUnallowedExtraSource(): void { + $data = $this->getPostApiData(); + + // Unallowed extra source. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Unallowed source(s)'); + $this->plugin->validateSources(['source' => [123, 456]] + $data); + } + + /** + * @covers ::validateUrls + */ + public function testValidateUrls(): void { + $data = $this->getPostApiData(); + + // Allowed URLs. + $this->plugin->validateUrls($data); + $this->assertTrue(TRUE); + + // Any URL allowed. + $this->plugin->validateUrls([ + 'provider' => 'test-provider-any', + 'url' => 'https://test-any.test/anything', + ] + $data); + $this->assertTrue(TRUE); + + } + + /** + * @covers ::validateUrls + */ + public function testValidateUrlsEmptyDocumentUrl(): void { + $data = $this->getPostApiData(); + // Empty URL. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Unallowed document URL'); + $this->plugin->validateUrls(['url' => ''] + $data); + } + + /** + * @covers ::validateUrls + */ + public function testValidateUrlsUnallowedDocuemtnUrl(): void { + $data = $this->getPostApiData(); + // Unallowed URL. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Unallowed document URL'); + $this->plugin->validateUrls(['url' => 'https://wrong.test/'] + $data); + } + + /** + * @covers ::validateUrls + */ + public function testValidateUrlsUnallowedImageUrl(): void { + $data = $this->getPostApiData(); + $data['image']['url'] = 'https://wrong.test/test.jpg'; + + // Unallowed image URL. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Unallowed image URL'); + $this->plugin->validateUrls($data); + } + + /** + * @covers ::validateUrls + */ + public function testValidateUrlsUnallowedFileUrl(): void { + $data = $this->getPostApiData(); + $data['file'][0]['url'] = 'https://wrong.test/test.pdf'; + + // Unallowed file URL. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Unallowed file URL'); + $this->plugin->validateUrls($data); + } + + /** + * @covers ::validateUrl + */ + public function testValidateUrl(): void { + $this->assertTrue($this->plugin->validateUrl('https://test.test/test', '#^https://test\.test/#')); + } + + /** + * @covers ::sanitizeTerms + */ + public function testSanitizeTerms(): void { + $statement = $this->createStatementMock('fetchAllKeyed', [123 => 123]); + $select = $this->createSelectMock($statement); + $database = $this->createDatabaseMock($select); + + $plugin = $this->createDummyPlugin(services: [ + 'database' => $database, + ]); + + $terms = $plugin->sanitizeTerms('test', [123]); + $this->assertSame([123 => 123], $terms); + + $terms = $plugin->sanitizeTerms('test', [456]); + $this->assertSame([], $terms); + + $terms = $plugin->sanitizeTerms('test', []); + $this->assertSame([], $terms); + } + + /** + * @covers ::sanitizeString + */ + public function testSanitizeString(): void { + $this->assertSame('', $this->plugin->sanitizeString('')); + $this->assertSame('Test something', $this->plugin->sanitizeString('Test something')); + } + + /** + * @covers ::sanitizeText + */ + public function testSanitizeText(): void { + $this->assertSame('', $this->plugin->sanitizeText('')); + $this->assertSame('', $this->plugin->sanitizeText(' ')); + $this->assertSame('', $this->plugin->sanitizeText("\n")); + $this->assertSame('test something', $this->plugin->sanitizeText('test something')); + $this->assertSame('**test**', $this->plugin->sanitizeText('**test**')); + $this->assertSame('**test**', $this->plugin->sanitizeText('test')); + $this->assertSame('test', $this->plugin->sanitizeText('test')); + } + + /** + * @covers ::sanitizeDate + */ + public function testSanitizeDate(): void { + $date = '2024-02-01T17:00:00-09:00'; + $this->assertSame('', $this->plugin->sanitizeDate('')); + $this->assertSame('', $this->plugin->sanitizeDate('invalid')); + $this->assertSame('2024-02-02', $this->plugin->sanitizeDate($date)); + $this->assertSame('2024-02-02T02:00:00', $this->plugin->sanitizeDate($date, FALSE)); + } + + /** + * @covers ::sanitizeUrl + */ + public function testSanitizeUrl(): void { + $pattern = '#^https://#'; + $this->assertSame('', $this->plugin->sanitizeUrl('', $pattern)); + $this->assertSame('', $this->plugin->sanitizeUrl('test', $pattern)); + $this->assertSame('https://test.test', $this->plugin->sanitizeUrl('https://test.test', $pattern)); + } + + /** + * @covers ::setField + */ + public function testSetField(): void { + $entity = $this->createEntity('node', 'report'); + + // Unknown field. + $this->plugin->setField($entity, 'test', 'test'); + $this->assertTrue(TRUE); + + // NULL. + $field_name = 'title'; + $this->plugin->setField($entity, $field_name, NULL); + $this->assertTrue($entity->get($field_name)->isEmpty()); + + // Single value. + $field_name = 'title'; + $this->plugin->setField($entity, $field_name, 'test'); + $this->assertSame('test', $entity->get($field_name)->value); + + // Multiple values. + $field_name = 'field_country'; + $this->plugin->setField($entity, $field_name, [123, 456]); + $this->assertSame(123, $entity->get($field_name)->get(0)->target_id); + $this->assertSame(456, $entity->get($field_name)->get(1)->target_id); + } + + /** + * @covers ::setStringField + */ + public function testSetStringField(): void { + $entity = $this->createEntity('node', 'report'); + + $field_name = 'title'; + $this->plugin->setStringField($entity, $field_name, 'test'); + $this->assertSame('test', $entity->get($field_name)->value); + } + + /** + * @covers ::setTextField + */ + public function testSetTextField(): void { + $entity = $this->createEntity('node', 'report'); + $field_name = 'body'; + + $this->plugin->setTextField($entity, $field_name, 'test'); + $this->assertSame('test', $entity->get($field_name)->value); + $this->assertSame(NULL, $entity->get($field_name)->first()->format); + + $this->plugin->setTextField($entity, $field_name, '

test

', 3); + $this->assertSame('### test', $entity->get($field_name)->value); + $this->assertSame(NULL, $entity->get($field_name)->first()->format); + + $this->plugin->setTextField($entity, $field_name, '

test

', 3, 'markdown'); + $this->assertSame('### test', $entity->get($field_name)->value); + $this->assertSame('markdown', $entity->get($field_name)->first()->format); + } + + /** + * @covers ::setDateField + */ + public function testSetDateField(): void { + $entity = $this->createEntity('node', 'report'); + $date = '2024-02-01T17:00:00-09:00'; + + $field_name = 'field_original_publication_date'; + $this->plugin->setDateField($entity, $field_name, $date); + $this->assertSame('2024-02-02', $entity->get($field_name)->value); + + $field_name = 'field_embargo_date'; + $this->plugin->setDateField($entity, $field_name, $date, FALSE); + $this->assertSame('2024-02-02T02:00:00', $entity->get($field_name)->value); + } + + /** + * @covers ::setTermField + */ + public function testSetTermField(): void { + $statement = $this->createStatementMock('fetchAllKeyed', [123 => 123]); + $select = $this->createSelectMock($statement); + $database = $this->createDatabaseMock($select); + + $plugin = $this->createDummyPlugin(services: [ + 'database' => $database, + ]); + + $entity = $this->createEntity('node', 'report'); + + $field_name = 'field_country'; + $plugin->setTermField($entity, $field_name, 'country', [123]); + $this->assertSame(123, $entity->get($field_name)->first()->target_id); + } + + /** + * @covers ::setUrlField + */ + public function testSetUrlField(): void { + $entity = $this->createEntity('node', 'report'); + + $field_name = 'field_origin_notes'; + $this->plugin->setUrlField($entity, $field_name, 'https://test.test', '#^https://#'); + $this->assertSame('https://test.test', $entity->get($field_name)->value); + } + + /** + * @covers ::setReliefWebFileField + */ + public function testSetReliefWebField(): void { + $entity_repository = $this->createMock(EntityRepositoryInterface::class); + $http_client = $this->createMock(Client::class); + + $plugin = $this->createDummyPlugin(services: [ + 'entity.repository' => $entity_repository, + 'http_client' => $http_client, + ]); + + $entity = $this->createEntity('node', 'report'); + $entity->uuid = $plugin->generateUuid('test-node'); + $item_definition = $entity->field_file->getItemDefinition(); + + $data1 = [ + 'url' => 'https://test.test/test1.pdf', + 'checksum' => hash('sha256', 'test1'), + 'description' => 'test file1', + ]; + + $data2 = [ + 'url' => 'https://test.test/test2.pdf', + 'checksum' => hash('sha256', 'test2'), + 'description' => 'test file2', + ]; + + $uuid1 = $plugin->generateUuid($data1['url'], $entity->uuid()); + $uuid2 = $plugin->generateUuid($data2['url'], $entity->uuid()); + + $file_uuid1 = $plugin->generateUuid($uuid1 . $data1['checksum'], $entity->uuid()); + $file_uuid2 = $plugin->generateUuid($uuid2 . $data1['checksum'], $entity->uuid()); + + // No file URI on purpose to skip the ReliefWebFile::getFilePageCount() and + // prevent a warning because the file doesn't exist. + $file1 = $this->createEntity('file', 'file'); + $file1->uuid = $file_uuid1; + $file1->setFilename('test1.pdf'); + $file1->setMimeType('application/pdf'); + $file1->setSize(4); + + $item1 = ReliefWebFile::createInstance($item_definition); + $item1->setValue([ + 'uuid' => $uuid1, + 'revision_id' => 0, + 'file_uuid' => $file1->uuid(), + 'file_name' => $file1->getFilename(), + 'file_mime' => $file1->getMimeType(), + 'file_size' => $file1->getSize(), + 'page_count' => 1, + 'description' => 'item1', + ]); + + $entity_repository->expects($this->any()) + ->method('loadEntityByUuid') + ->willReturnMap([ + ['file', $file_uuid1, $file1], + ['file', $file_uuid2, NULL], + ]); + + $http_client->expects($this->any()) + ->method('get') + ->willThrowException(new \Exception('test')); + + // Test existing file is removed if no file is provided. + $entity->field_file->setValue([$item1->getValue()]); + $plugin->setReliefWebFileField($entity, 'field_file', []); + $this->assertTrue($entity->field_file->isEmpty()); + + // Test existing file is removed if no valid file is provided. + $entity->field_file->setValue([$item1->getValue()]); + $plugin->setReliefWebFileField($entity, 'field_file', [ + ['url' => 'missing-checksum-test'], + ]); + $this->assertTrue($entity->field_file->isEmpty()); + + // Test existing file is removed if no file is provided. + $entity->field_file->setValue([$item1->getValue()]); + $plugin->setReliefWebFileField($entity, 'field_file', [$data1]); + $this->assertSame($data1['description'], $entity->field_file->first()->description); + + // Test new file. + $entity->field_file->setValue(NULL); + $plugin->setReliefWebFileField($entity, 'field_file', [$data1]); + $this->assertSame($data1['description'], $entity->field_file->first()->description); + + // Test new file that cannot be retrieved. + $entity->field_file->setValue(NULL); + $plugin->setReliefWebFileField($entity, 'field_file', [$data2]); + $this->assertTrue($entity->field_file->isEmpty()); + + } + + /** + * @covers ::setReliefWebFileField + */ + public function testSetReliefWebFieldUnknownField(): void { + $plugin = $this->createDummyPlugin(); + + $entity = $this->createEntity('node', 'report'); + + // Unknown field, nothing happens. + $plugin->setReliefWebFileField($entity, 'unknown_field', []); + $this->assertTrue($entity->field_image->isEmpty()); + } + + /** + * @covers ::setReliefWebFileField + */ + public function testSetReliefWebFieldNoFiles(): void { + $plugin = $this->createDummyPlugin(); + + $entity = $this->createEntity('node', 'report'); + + // Unknown field, nothing happens. + $plugin->setReliefWebFileField($entity, 'field_file', []); + $this->assertTrue($entity->field_image->isEmpty()); + } + + /** + * @covers ::setImageField + */ + public function testSetImageField(): void { + $entity_repository = $this->createMock(EntityRepositoryInterface::class); + $entity_type_manager = $this->createMock(EntityTypeManagerInterface::class); + $entity_storage = $this->createMock(EntityStorageInterface::class); + $http_client = $this->createMock(Client::class); + + $plugin = $this->createDummyPlugin(services: [ + 'entity_type.manager' => $entity_type_manager, + 'entity.repository' => $entity_repository, + 'http_client' => $http_client, + ]); + + $entity = $this->createEntity('node', 'report'); + $entity->uuid = $plugin->generateUuid('test-node'); + + $data1 = [ + 'url' => 'https://test.test/test1.png', + 'checksum' => hash('sha256', 'test1'), + 'description' => 'test image1', + ]; + + $data2 = [ + 'url' => 'https://test.test/test2.png', + 'checksum' => hash('sha256', 'test2'), + 'description' => 'test image2', + ]; + + $data3 = [ + 'url' => 'https://test.test/test3.png', + 'checksum' => hash('sha256', 'test3'), + 'description' => 'test image3', + ]; + + $data4 = [ + 'url' => 'https://test.test/test4.png', + 'checksum' => hash('sha256', 'test4'), + 'description' => 'test image4', + ]; + + $media_uuid1 = $plugin->generateUuid($data1['checksum'] . $data1['url'], $entity->uuid()); + $media_uuid2 = $plugin->generateUuid($data2['checksum'] . $data2['url'], $entity->uuid()); + $media_uuid3 = $plugin->generateUuid($data3['checksum'] . $data3['url'], $entity->uuid()); + $media_uuid4 = $plugin->generateUuid($data4['checksum'] . $data4['url'], $entity->uuid()); + + $media1 = $this->createEntity('media', 'image_report'); + $media1->mid = 12; + $media1->uuid = $media_uuid1; + + $media2 = $this->createEntity('media', 'image_report'); + $media2->mid = 34; + $media2->uuid = $media_uuid2; + + $media3 = $this->createEntity('media', 'image_report'); + $media3->mid = 56; + $media3->uuid = $media_uuid3; + + $media4 = $this->createEntity('media', 'image_report'); + $media4->mid = 78; + $media4->uuid = $media_uuid4; + + $file1 = $this->createEntity('file', 'file'); + $file1->uuid = $plugin->generateUuid($media_uuid1, $media_uuid1); + $file1->setFilename('test1.png'); + $file1->setMimeType('image/png'); + $file1->setFileUri('public://test1.png'); + $file1->setSize(4); + + $file2 = $this->createEntity('file', 'file'); + $file2->uuid = $plugin->generateUuid($media_uuid2, $media_uuid2); + $file2->setFilename('test2.png'); + $file2->setMimeType('image/png'); + $file2->setFileUri('public://test2.png'); + $file2->setSize(4); + + $file3 = $this->createEntity('file', 'file'); + $file3->uuid = $plugin->generateUuid($media_uuid3, $media_uuid3); + $file3->setFilename('test3.png'); + $file3->setMimeType('image/png'); + $file3->setFileUri('public://test3.png'); + $file3->setSize(4); + + $map = [ + $media1->uuid() => $media1, + $media2->uuid() => $media2, + $media3->uuid() => $media3, + $media4->uuid() => $media4, + $file1->uuid() => $file1, + $file2->uuid() => $file2, + $file3->uuid() => $file3, + ]; + + $entity_repository->expects($this->any()) + ->method('loadEntityByUuid') + ->willReturnMap([ + ['media', $media_uuid1, NULL], + ['media', $media_uuid2, $media2], + ['media', $media_uuid3, $media3], + ['media', $media_uuid4, $media4], + ['file', $file1->uuid(), $file1], + ['file', $file2->uuid(), $file2], + ['file', $file3->uuid(), $file3], + ]); + + $entity_type_manager->expects($this->any()) + ->method('getStorage') + ->willReturn($entity_storage); + + $entity_storage->expects($this->any()) + ->method('create') + ->willReturnCallback(function (array $values) use ($map): ?MediaInterface { + return $map[$values['uuid']] ?? NULL; + }); + + $http_client->expects($this->any()) + ->method('get') + ->willThrowException(new \Exception('test')); + + // Test new media. + $plugin->setImageField($entity, 'field_image', $data1); + $this->assertSame($media_uuid1, $entity->field_image->first()->entity->uuid()); + $this->assertSame('test image1', $entity->field_image->first()->entity->field_description->value); + + // Test different media. + $entity->field_image->setValue($media1); + $plugin->setImageField($entity, 'field_image', $data2); + $this->assertSame($media_uuid2, $entity->field_image->first()->entity->uuid()); + $this->assertSame('test image2', $entity->field_image->first()->entity->field_description->value); + + // Test existing media. + $entity->field_image->setValue($media3); + $plugin->setImageField($entity, 'field_image', $data3); + $this->assertSame($media_uuid3, $entity->field_image->first()->entity->uuid()); + $this->assertSame('test image3', $entity->field_image->first()->entity->field_description->value); + + // Test file creation failure. + $plugin->setImageField($entity, 'field_image', $data4); + $this->assertTrue($entity->field_image->isEmpty()); + } + + /** + * @covers ::setImageField + */ + public function testSetImageFieldUnknownField(): void { + $plugin = $this->createDummyPlugin(); + + $entity = $this->createEntity('node', 'report'); + + // Unknown field, nothing happens. + $plugin->setImageField($entity, 'unknown_field', [ + 'url' => 'test', + 'checksum' => 'test', + ]); + $this->assertTrue($entity->field_image->isEmpty()); + } + + /** + * @covers ::setImageField + */ + public function testSetImageFieldMissingImageProperties(): void { + $plugin = $this->createDummyPlugin(); + + $entity = $this->createEntity('node', 'report'); + + // Missing checksum or URL nothing happens. + $plugin->setImageField($entity, 'field_image', []); + $this->assertTrue($entity->field_image->isEmpty()); + } + + /** + * @covers ::createImageMedia + */ + public function testCreateImageMedia(): void { + $file = $this->createConfiguredMock(File::class, [ + 'uuid' => 'da5b8893-d6ca-5c1c-9a9c-91f40a2a3649', + 'getFilename' => 'test.png', + 'getMimeType' => 'image/png', + 'getFileUri' => 'public://test.png', + 'getSize' => 4, + 'setTemporary' => TRUE, + 'save' => TRUE, + ]); + + $entity_repository = $this->createConfiguredMock(EntityRepositoryInterface::class, [ + 'loadEntityByUuid' => $file, + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'entity.repository' => $entity_repository, + ]); + + $media = $plugin->createImageMedia( + bundle: 'image_report', + uuid: 'bda0e2da-4229-53aa-9206-db72dfdac519', + url: 'https://test.test/test.png', + checksum: hash('sha256', 'test'), + mimetype: 'image/png', + max_size: '8B', + alt: 'test image', + ); + $this->assertInstanceOf(MediaInterface::class, $media); + $this->assertSame('test image', $media->get('field_media_image')->first()->alt); + } + + /** + * @covers ::createReliefWebFileFieldItem + */ + public function testCreateReliefWebFileFieldItem(): void { + $file = $this->createConfiguredMock(File::class, [ + 'uuid' => 'da5b8893-d6ca-5c1c-9a9c-91f40a2a3649', + 'getFilename' => 'test.pdf', + 'getMimeType' => 'application/pdf', + 'getSize' => 4, + 'setTemporary' => TRUE, + 'save' => TRUE, + ]); + + $file_system = $this->createConfiguredMock(FileSystemInterface::class, [ + 'dirname' => 'test', + 'prepareDirectory' => TRUE, + 'saveData' => TRUE, + 'unlink' => TRUE, + ]); + + $entity_repository = $this->createConfiguredMock(EntityRepositoryInterface::class, [ + 'loadEntityByUuid' => $file, + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'file_system' => $file_system, + 'entity.repository' => $entity_repository, + ]); + + $entity = $this->createEntity('node', 'report'); + $definition = $entity->get('field_file')->getItemDefinition(); + + $item = $plugin->createReliefWebFileFieldItem( + definition: $definition, + uuid: 'bda0e2da-4229-53aa-9206-db72dfdac519', + url: 'https://test.test/test.pdf', + checksum: hash('sha256', 'test'), + mimetype: 'application/pdf', + max_size: '8B', + ); + $this->assertInstanceOf(ReliefWebFile::class, $item); + } + + /** + * @covers ::createReliefWebFileFieldItem + */ + public function testCreateReliefWebFileFieldItemExceptionValidation(): void { + $file = $this->createConfiguredMock(File::class, [ + // This will cause a validation error on the `file_uuid` property of + // the ReliefWebFile field item. + 'uuid' => NULL, + 'getFilename' => str_pad('test.pdf', 300, "a", \STR_PAD_LEFT), + 'getMimeType' => 'application/pdf', + 'getSize' => 4, + 'setTemporary' => TRUE, + 'save' => TRUE, + ]); + + $file_system = $this->createConfiguredMock(FileSystemInterface::class, [ + 'dirname' => 'test', + 'prepareDirectory' => TRUE, + 'saveData' => TRUE, + 'unlink' => TRUE, + ]); + + $entity_repository = $this->createConfiguredMock(EntityRepositoryInterface::class, [ + 'loadEntityByUuid' => $file, + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'file_system' => $file_system, + 'entity.repository' => $entity_repository, + ]); + + $entity = $this->createEntity('node', 'report'); + $definition = $entity->get('field_file')->getItemDefinition(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid field item data'); + + $plugin->createReliefWebFileFieldItem( + definition: $definition, + uuid: 'bda0e2da-4229-53aa-9206-db72dfdac519', + url: 'https://test.test/test.pdf', + checksum: hash('sha256', 'test'), + mimetype: 'application/pdf', + max_size: '8B', + ); + } + + /** + * @covers ::createFile + */ + public function testCreateFile(): void { + $file_system = $this->createConfiguredMock(FileSystemInterface::class, [ + 'dirname' => 'test', + 'prepareDirectory' => TRUE, + 'saveData' => TRUE, + 'unlink' => TRUE, + ]); + + $client = $this->createHttpClientMock([ + new Response(200, [ + 'Content-Length' => 4, + 'Content-Type' => 'application/pdf', + ], 'test'), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'file_system' => $file_system, + 'http_client' => $client, + ]); + + $this->assertInstanceOf(FileInterface::class, $plugin->createFile( + uuid: $plugin->generateUuid('test'), + uri: 'public://test.pdf', + name: 'test.pdf', + mimetype: 'application/pdf', + url: 'https://test.test/test.pdf', + checksum: hash('sha256', 'test'), + max_size: '8B', + validators: [] + )); + } + + /** + * @covers ::createFile + */ + public function testCreateFileExisting(): void { + $entity_repository = $this->createConfiguredMock(EntityRepositoryInterface::class, [ + 'loadEntityByUuid' => $this->createMock(FileInterface::class), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'entity.repository' => $entity_repository, + ]); + + $this->assertInstanceOf(FileInterface::class, $plugin->createFile( + uuid: $plugin->generateUuid('test'), + uri: 'public://test.pdf', + name: 'test.pdf', + mimetype: 'application/pdf', + url: 'https://test.test/test.pdf', + checksum: hash('sha256', 'test'), + max_size: '8B', + validators: [] + )); + } + + /** + * @covers ::createFile + */ + public function testCreateFileEmptyContent(): void { + $file_system = $this->createConfiguredMock(FileSystemInterface::class, [ + 'dirname' => 'test', + 'prepareDirectory' => FALSE, + 'saveData' => TRUE, + 'unlink' => TRUE, + ]); + + $client = $this->createHttpClientMock([ + new Response(200, [ + 'Content-Length' => 4, + 'Content-Type' => 'application/pdf', + ], ''), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'file_system' => $file_system, + 'http_client' => $client, + ]); + + $this->assertNull($plugin->createFile( + uuid: $plugin->generateUuid('test'), + uri: 'public://test.pdf', + name: 'test.pdf', + mimetype: 'application/pdf', + url: 'https://test.test/test.pdf', + checksum: hash('sha256', ''), + max_size: '8B', + validators: [] + )); + } + + /** + * @covers ::createFile + */ + public function testCreateFileExceptionCreateDirectory(): void { + $file_system = $this->createConfiguredMock(FileSystemInterface::class, [ + 'dirname' => 'test', + 'prepareDirectory' => FALSE, + 'saveData' => TRUE, + 'unlink' => TRUE, + ]); + + $client = $this->createHttpClientMock([ + new Response(200, [ + 'Content-Length' => 4, + 'Content-Type' => 'application/pdf', + ], 'test'), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'file_system' => $file_system, + 'http_client' => $client, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unable to create the destination directory'); + + $plugin->createFile( + uuid: $plugin->generateUuid('test'), + uri: 'public://test.pdf', + name: 'test.pdf', + mimetype: 'application/pdf', + url: 'https://test.test/test.pdf', + checksum: hash('sha256', 'test'), + max_size: '8B', + validators: [] + ); + } + + /** + * @covers ::createFile + */ + public function testCreateFileExceptionSaveData(): void { + $file_system = $this->createConfiguredMock(FileSystemInterface::class, [ + 'dirname' => 'test', + 'prepareDirectory' => TRUE, + 'saveData' => FALSE, + 'unlink' => TRUE, + ]); + + $client = $this->createHttpClientMock([ + new Response(200, [ + 'Content-Length' => 4, + 'Content-Type' => 'application/pdf', + ], 'test'), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'file_system' => $file_system, + 'http_client' => $client, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unable to copy the file'); + + $plugin->createFile( + uuid: $plugin->generateUuid('test'), + uri: 'public://test.pdf', + name: 'test.pdf', + mimetype: 'application/pdf', + url: 'https://test.test/test.pdf', + checksum: hash('sha256', 'test'), + max_size: '8B', + validators: [] + ); + } + + /** + * @covers ::createFile + */ + public function testCreateFileExceptionValidation(): void { + $file_system = $this->createConfiguredMock(FileSystemInterface::class, [ + 'dirname' => 'test', + 'prepareDirectory' => TRUE, + 'saveData' => TRUE, + 'unlink' => TRUE, + ]); + + $client = $this->createHttpClientMock([ + new Response(200, [ + 'Content-Length' => 4, + 'Content-Type' => 'application/pdf', + ], 'test'), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'file_system' => $file_system, + 'http_client' => $client, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid file'); + + $plugin->createFile( + uuid: $plugin->generateUuid('test'), + uri: 'public://test.pdf', + name: str_pad('test.pdf', 300, "a", \STR_PAD_LEFT), + mimetype: 'application/pdf', + url: 'https://test.test/test.pdf', + checksum: hash('sha256', 'test'), + max_size: '8B', + validators: ['FileNameLength' => []] + ); + } + + /** + * @covers ::getRemoteFileContent + */ + public function testGetRemoteFileContent(): void { + $client = $this->createHttpClientMock([ + new Response(200, [ + 'Content-Length' => 4, + 'Content-Type' => 'application/pdf', + ], 'test'), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'http_client' => $client, + ]); + + $uri = 'https://test.test/test.pdf'; + $checksum = hash('sha256', 'test'); + $mimetype = 'application/pdf'; + $max_size = '8B'; + + $content = $plugin->getRemoteFileContent($uri, $checksum, $mimetype, $max_size); + $this->assertSame('test', $content); + } + + /** + * @covers ::getRemoteFileContent + */ + public function testGetRemoteFileContentInvalidMimetype(): void { + $client = $this->createHttpClientMock([ + new Response(200, [ + 'Content-Length' => 4, + 'Content-Type' => 'test', + ], 'test'), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'http_client' => $client, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('File type is not "application/pdf".'); + $plugin->getRemoteFileContent('https://test.test/test.pdf', 'test', 'application/pdf', '8B'); + } + + /** + * @covers ::getRemoteFileContent + */ + public function testGetRemoteFileContentTooLargeHeader(): void { + $client = $this->createHttpClientMock([ + new Response(200, [ + 'Content-Length' => 16, + 'Content-Type' => 'application/pdf', + ], 'test'), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'http_client' => $client, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('File is too large.'); + $plugin->getRemoteFileContent('https://test.test/test.pdf', 'test', 'application/pdf', '8B'); + } + + /** + * @covers ::getRemoteFileContent + */ + public function testGetRemoteFileContentTooLargeContent(): void { + $client = $this->createHttpClientMock([ + new Response(200, [ + 'Content-Type' => 'application/pdf', + ], 'testtest'), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'http_client' => $client, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('File is too large.'); + $plugin->getRemoteFileContent('https://test.test/test.pdf', 'test', 'application/pdf', '4B'); + } + + /** + * @covers ::getRemoteFileContent + */ + public function testGetRemoteFileContentInvalidChecksum(): void { + $client = $this->createHttpClientMock([ + new Response(200, [ + 'Content-Length' => 4, + 'Content-Type' => 'application/pdf', + ], 'test'), + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'http_client' => $client, + ]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid file checksum.'); + $plugin->getRemoteFileContent('https://test.test/test.pdf', 'test', 'application/pdf', '8B'); + } + + /** + * @covers ::validateFile + */ + public function testValidateFile(): void { + $file = $this->createEntity('file', 'file'); + $file->setFileUri('public:://test.pdf'); + $file->setFileName('test.pdf'); + + $validators = []; + $this->assertSame([], $this->plugin->validateFile($file, $validators)); + + $validators = ['FileNameLength' => []]; + $this->assertSame([], $this->plugin->validateFile($file, $validators)); + + $file->setFileName(str_pad($file->getFileName(), 300, "a", \STR_PAD_LEFT)); + $this->assertStringContainsString('name exceeds', (string) $this->plugin->validateFile($file, $validators)[0]); + } + + /** + * @covers ::generateUuid + */ + public function testGenerateUuid(): void { + $uuid = 'bda0e2da-4229-53aa-9206-db72dfdac519'; + $this->assertSame($uuid, $this->plugin->generateUuid('https://test.test')); + } + + /** + * @covers ::guessFileMimeType + */ + public function testGuessFileMimeType(): void { + $uri = 'public://test.pdf'; + $this->assertSame('application/pdf', $this->plugin->guessFileMimeType($uri)); + $this->assertSame('application/pdf', $this->plugin->guessFileMimeType($uri, ['application/pdf'])); + } + + /** + * @covers ::guessFileMimeType + */ + public function testGuessFileMimeTypeUnknown(): void { + $mime_type_guesser = $this->createConfiguredMock(MimeTypeGuesserInterface::class, [ + 'guessMimeType' => NULL, + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'file.mime_type.guesser' => $mime_type_guesser, + ]); + + $uri = 'public://test.dummy'; + $this->expectException(ContentProcessorException::class); + $plugin->guessFileMimeType($uri); + } + + /** + * @covers ::guessFileMimeType + */ + public function testGuessFileMimeTypeUnallowed(): void { + $uri = 'public://test.pdf'; + $this->expectException(ContentProcessorException::class); + $this->plugin->guessFileMimeType($uri, ['image/png']); + } + + /** + * @covers ::getDefaultLangcode + */ + public function testGetDefaultLangcode(): void { + $this->assertSame(\Drupal::languageManager()->getDefaultLanguage()->getId(), $this->plugin->getDefaultLangcode()); + } + + /** + * Create a mock of a select query statement. + * + * @param string $method + * The method to call on the statement object. + * @param mixed $value + * The value to be returned. + * + * @return \Drupal\Core\Database\StatementInterface + * The statement mock. + */ + protected function createStatementMock(string $method, mixed $value): StatementInterface { + $statement = $this->createMock(StatementInterface::class); + + $statement->expects($this->any()) + ->method($method) + ->willReturn($value); + + return $statement; + } + + /** + * Create a mock of a select query. + * + * @param \Drupal\Core\Database\StatementInterface $statement + * The statement to return when executing the query. + * + * @return \Drupal\Core\Database\Query\SelectInterface + * The select query. + */ + protected function createSelectMock(StatementInterface $statement): SelectInterface { + $select = $this->createMock(SelectInterface::class); + + $select->expects($this->any()) + ->method('fields') + ->will($this->returnSelf()); + + $select->expects($this->any()) + ->method('condition') + ->will($this->returnSelf()); + + $select->expects($this->any()) + ->method('execute') + ->willReturn($statement); + + return $select; + } + + /** + * Create a mock of a database connection. + * + * @param \Drupal\Core\Database\Query\SelectInterface $select + * The select query to return when calling select(). + * + * @return \Drupal\Core\Database\Connection + * The database connection. + */ + protected function createDatabaseMock(SelectInterface $select): Connection { + $connection = $this->createMock(Connection::class); + + $connection->expects($this->any()) + ->method('select') + ->willReturn($select); + + return $connection; + } + + /** + * Create a mock of an HTTP client. + * + * @param array $responses + * List of responses. + * + * @return \GuzzleHttp\ClientInterface + * The client. + */ + protected function createHttpClientMock(array $responses): ClientInterface { + $mock = new MockHandler($responses); + $handlerStack = HandlerStack::create($mock); + return new Client(['handler' => $handlerStack]); + } + + /** + * Create an entity. + * + * @param string|null $entity_type_id + * The entity type ID. Defaults to the one handled by the content processor + * plugin. + * @param string|null $bundle + * The entity bundle. Defaults to the one handled by the content processor + * plugin. + * + * @return \Drupal\Core\Entity\ContentEntityInterface + * The entity. + */ + protected function createEntity(?string $entity_type_id = NULL, ?string $bundle = NULL): ContentEntityInterface { + $entity_type_id = $entity_type_id ?? $this->plugin->getEntityType(); + $bundle = $bundle ?? $this->plugin->getEntityBundle(); + + $storage = \Drupal::entityTypeManager()->getStorage($entity_type_id); + + $class = $storage->getEntityClass($bundle); + + // phpcs:ignore Drupal.Functions.DiscouragedFunctions.Discouraged + return eval("return new class([], '$entity_type_id', '$bundle') extends $class { + public function save() { return NULL; } + };"); + } + + /** + * Create a dummy content processor plugin. + * + * @param array $definition + * Plugin definition. + * @param array $services + * Service overrides. + * + * @return \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface + * The dummy plugin. + */ + protected function createDummyPlugin(array $definition = [], array $services = []): ContentProcessorPluginInterface { + $container = \drupal::getContainer(); + + $definition += [ + 'id' => 'reliefweb_post_api.content_processor.dummy', + 'label' => new TranslatableMarkup('Dummy content processor'), + 'entityType' => 'dummy', + 'entityBundle' => 'dummy', + ]; + + $services = [ + $services['entity_type.manager'] ?? $container->get('entity_type.manager'), + $services['entity.repository'] ?? $container->get('entity.repository'), + $services['database'] ?? $container->get('database'), + $services['logger.factory'] ?? $container->get('logger.factory'), + $services['extension.path.resolver'] ?? $container->get('extension.path.resolver'), + $services['http_client'] ?? $container->get('http_client'), + $services['file_system'] ?? $container->get('file_system'), + $services['file.validator'] ?? $container->get('file.validator'), + $services['file.mime_type.guesser'] ?? $container->get('file.mime_type.guesser'), + $services['language_manager'] ?? $container->get('language_manager'), + $services['reliefweb_post_api.provider.manager'] ?? $container->get('reliefweb_post_api.provider.manager'), + ]; + + return new ($this->plugin::class)([], $definition['id'], $definition, ...$services); + } + + /** + * Get some POST API test data. + * + * @return array + * The data. + */ + protected function getPostApiData(): array { + $bundle = $this->plugin->getEntityBundle(); + if (!isset($this->postApiData[$bundle])) { + $file = __DIR__ . '/../../../data/data-' . $bundle . '.json'; + $data = json_decode(file_get_contents($file), TRUE); + $data['provider'] = 'test-provider'; + $data['bundle'] = $bundle; + $this->postApiData[$bundle] = $data; + } + return $this->postApiData[$bundle]; + } + + /** + * Get the content for test file. + * + * @param string $file_name + * File name. + * + * @return string + * Content of the file. + */ + protected function getFileContent(string $file_name): string { + $file = __DIR__ . '/../../../data/data-' . $file_name; + return file_get_contents($file); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginManagerTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginManagerTest.php new file mode 100644 index 000000000..25f6cea26 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginManagerTest.php @@ -0,0 +1,77 @@ +contentProcessorPluginManager = \Drupal::service('plugin.manager.reliefweb_post_api.content_processor'); + } + + /** + * @covers ::__construct + */ + public function testConstructor(): void { + $container = \drupal::getContainer(); + + $manager = new ContentProcessorPluginManager( + $container->get('container.namespaces'), + $container->get('cache.discovery'), + $container->get('module_handler') + ); + + $this->assertInstanceOf(ContentProcessorPluginManager::class, $manager); + } + + /** + * @covers ::getPlugin + */ + public function testGetPlugin(): void { + $plugin = $this->contentProcessorPluginManager->getPlugin('reliefweb_post_api.content_processor.report'); + $this->assertInstanceOf(Report::class, $plugin); + + $plugin = $this->contentProcessorPluginManager->getPlugin('unknown'); + $this->assertNull($plugin); + } + + /** + * @covers ::getPluginByBundle + */ + public function testGetPluginByBundle(): void { + $plugin = $this->contentProcessorPluginManager->getPluginByBundle('report'); + $this->assertInstanceOf(Report::class, $plugin); + + $plugin = $this->contentProcessorPluginManager->getPluginByBundle('unknown'); + $this->assertNull($plugin); + + $plugin = $this->contentProcessorPluginManager->getPluginByBundle(''); + $this->assertNull($plugin); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php new file mode 100644 index 000000000..c4c264650 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php @@ -0,0 +1,113 @@ +plugin = $this->contentProcessorPluginManager->getPluginByBundle('report'); + } + + /** + * @covers ::getPluginLabel + */ + public function testGetPluginLabel(): void { + $this->assertEquals('Report content processor', (string) $this->plugin->getPluginLabel()); + } + + /** + * @covers ::getEntityType + */ + public function testGetEntityType(): void { + $this->assertEquals('node', $this->plugin->getEntityType()); + } + + /** + * @covers ::getEntityBundle + */ + public function testGetEntityBundle(): void { + $this->assertEquals('report', $this->plugin->getEntityBundle()); + } + + /** + * @covers ::process + */ + public function testProcess(): void { + $entity_repository = $this->createMock(EntityRepositoryInterface::class); + + $statement = $this->createStatementMock('fetchAllKeyed', [123 => 123]); + $select = $this->createSelectMock($statement); + $database = $this->createDatabaseMock($select); + + // Create a new instance of the current plugin with some mocked services. + $plugin = $this->createDummyPlugin($this->plugin->getPluginDefinition(), [ + 'entity.repository' => $entity_repository, + 'database' => $database, + ]); + + $data = ['source' => [123]] + $this->getPostApiData(); + unset($data['file']); + unset($data['image']); + + $entity = $this->createEntity('node', 'report'); + $entity->uuid = $plugin->generateUuid($data['url']); + + $entity_repository->expects($this->any()) + ->method('loadEntityByUuid') + ->willReturnMap([ + ['node', $entity->uuid(), $entity], + ]); + + $plugin->process($data); + $this->assertSame($data['title'], $entity->label()); + } + + /** + * @covers ::process + */ + public function testProcessWrongBundle(): void { + $entity_repository = $this->createMock(EntityRepositoryInterface::class); + + // Create a new instance of the current plugin with some mocked services. + $plugin = $this->createDummyPlugin($this->plugin->getPluginDefinition(), [ + 'entity.repository' => $entity_repository, + ]); + + $data = ['source' => [123]] + $this->getPostApiData(); + + $entity = $this->createEntity('node', 'training'); + $entity->nid = 123; + $entity->uuid = $plugin->generateUuid($data['url']); + $entity->enforceIsNew(FALSE); + + $entity_repository->expects($this->any()) + ->method('loadEntityByUuid') + ->willReturnMap([ + ['node', $entity->uuid(), $entity], + ]); + + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('is not a report'); + + $plugin->process($data); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Services/ProviderManagerTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Services/ProviderManagerTest.php new file mode 100644 index 000000000..010cd5676 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Services/ProviderManagerTest.php @@ -0,0 +1,43 @@ + 'test-provider-key', + 'url_pattern' => '@^https://test.test/@', + ]; + new Settings($settings); + + $manager = \Drupal::service('reliefweb_post_api.provider.manager'); + + $provider = $manager->getProvider(''); + $this->assertNull($provider); + + $provider = $manager->getProvider('test-unknow'); + $this->assertNull($provider); + + $provider = $manager->getProvider('test-provider'); + $this->assertInstanceOf(ProviderInterface::class, $provider); + } + +} From 490a7b93270fa4dd9a23e7499d710bbb3bab9b7a Mon Sep 17 00:00:00 2001 From: orakili Date: Tue, 13 Feb 2024 04:46:38 +0000 Subject: [PATCH 06/73] chore: enable reliefweb_post_api module Refs: RW-831 --- config/core.extension.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/core.extension.yml b/config/core.extension.yml index ed0923fa0..04db8bbaf 100644 --- a/config/core.extension.yml +++ b/config/core.extension.yml @@ -73,6 +73,7 @@ module: reliefweb_key_figures: 0 reliefweb_meta: 0 reliefweb_moderation: 0 + reliefweb_post_api: 0 reliefweb_reporting: 0 reliefweb_revisions: 0 reliefweb_rivers: 0 From 951f15c5b86e09f1eba877035e14e1499ccf6203 Mon Sep 17 00:00:00 2001 From: Peter Lieverdink Date: Tue, 20 Feb 2024 12:16:24 +1100 Subject: [PATCH 07/73] feat: Add an override to allow PUT on the new API handler in Drupal. Refs: RW-892 --- docker/Dockerfile | 1 + docker/etc/nginx/map_block_http_methods.conf | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 docker/etc/nginx/map_block_http_methods.conf diff --git a/docker/Dockerfile b/docker/Dockerfile index 200c96625..9782d2852 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -62,6 +62,7 @@ COPY --from=builder /srv/www/composer.patches.json /srv/www/composer.patches.jso COPY --from=builder /srv/www/composer.lock /srv/www/composer.lock COPY --from=builder /srv/www/PATCHES /srv/www/PATCHES COPY --from=builder /srv/www/scripts /srv/www/scripts +COPY --from=builder /src/www/docker/etc/nginx/map_block_http_methods.conf /etc/nginx/map_block_http_methods.conf COPY --from=builder /srv/www/docker/etc/nginx/apps/drupal/drupal.conf /etc/nginx/apps/drupal/drupal.conf COPY --from=builder /srv/www/docker/etc/nginx/custom /etc/nginx/custom/ COPY --from=builder /srv/www/docker/etc/nginx/sites-enabled/01_uuid.conf /etc/nginx/sites-enabled/01_uuid.conf diff --git a/docker/etc/nginx/map_block_http_methods.conf b/docker/etc/nginx/map_block_http_methods.conf new file mode 100644 index 000000000..8b8ff1f23 --- /dev/null +++ b/docker/etc/nginx/map_block_http_methods.conf @@ -0,0 +1,18 @@ +## Override the built-in list of permitted HTTP methods since Reliefweb +## needs to permit PUT as well for the new reports posting API. +## +## The original perusio nginx used weird reverse logic, so add a 0 for methods +## that should not be blocked and default 1 "blocked" for the rest. +## +## See https://humanitarian.atlassian.net/browse/RW-892 + +map $request_method $not_allowed_method { + default 1; + GET 0; + HEAD 0; + POST 0; + OPTIONS 0; + PATCH 0; + PUT 0; + DELETE 0; +} From d3ff3a0a6a44a9fa08d5add91c255e898ffa3f1f Mon Sep 17 00:00:00 2001 From: Peter Lieverdink Date: Tue, 20 Feb 2024 01:21:12 +0000 Subject: [PATCH 08/73] fix: cafuego cannot type good --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9782d2852..1ad63848b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -62,7 +62,7 @@ COPY --from=builder /srv/www/composer.patches.json /srv/www/composer.patches.jso COPY --from=builder /srv/www/composer.lock /srv/www/composer.lock COPY --from=builder /srv/www/PATCHES /srv/www/PATCHES COPY --from=builder /srv/www/scripts /srv/www/scripts -COPY --from=builder /src/www/docker/etc/nginx/map_block_http_methods.conf /etc/nginx/map_block_http_methods.conf +COPY --from=builder /srv/www/docker/etc/nginx/map_block_http_methods.conf /etc/nginx/map_block_http_methods.conf COPY --from=builder /srv/www/docker/etc/nginx/apps/drupal/drupal.conf /etc/nginx/apps/drupal/drupal.conf COPY --from=builder /srv/www/docker/etc/nginx/custom /etc/nginx/custom/ COPY --from=builder /srv/www/docker/etc/nginx/sites-enabled/01_uuid.conf /etc/nginx/sites-enabled/01_uuid.conf From 3bd84a3ab6c907a088a1e5ee8d7a2cc10891c3d2 Mon Sep 17 00:00:00 2001 From: orakili Date: Fri, 23 Feb 2024 04:06:02 +0000 Subject: [PATCH 09/73] refactor: use custom entity for providers Refs: RW-831 --- ...er.reliefweb_post_api_provider.default.yml | 143 +++++++ ...er.reliefweb_post_api_provider.default.yml | 110 ++++++ ...b_post_api_provider.field_document_url.yml | 23 ++ ...efweb_post_api_provider.field_file_url.yml | 23 ++ ...fweb_post_api_provider.field_image_url.yml | 23 ++ ...liefweb_post_api_provider.field_notify.yml | 20 + ...liefweb_post_api_provider.field_source.yml | 30 ++ ...reliefweb_post_api_provider.field_user.yml | 34 ++ ...eb_post_api_provider.field_webhook_url.yml | 23 ++ ...b_post_api_provider.field_document_url.yml | 19 + ...efweb_post_api_provider.field_file_url.yml | 19 + ...fweb_post_api_provider.field_image_url.yml | 19 + ...liefweb_post_api_provider.field_notify.yml | 18 + ...liefweb_post_api_provider.field_source.yml | 20 + ...reliefweb_post_api_provider.field_user.yml | 20 + ...eb_post_api_provider.field_webhook_url.yml | 19 + .../reliefweb_post_api.links.action.yml | 5 + .../reliefweb_post_api.links.menu.yml | 6 + .../reliefweb_post_api.links.task.yml | 17 + .../reliefweb_post_api.permissions.yml | 4 + .../reliefweb_post_api.routing.yml | 15 +- .../reliefweb_post_api.services.yml | 7 +- .../reliefweb_post_api/schemas/report.json | 5 + .../src/Attribute/ContentProcessor.php | 15 +- .../src/Controller/ReliefWebPostApi.php | 64 ++-- .../src/Entity/Provider.php | 193 ++++++++-- .../src/Entity/ProviderInterface.php | 17 +- .../src/Form/ProviderForm.php | 82 ++++ .../src/Plugin/ContentProcessorPluginBase.php | 90 +++-- .../ContentProcessorPluginInterface.php | 10 +- .../Plugin/ContentProcessorPluginManager.php | 30 +- ...ContentProcessorPluginManagerInterface.php | 27 ++ .../Field/FieldFormatter/ApiKeyFormatter.php | 42 ++ .../src/Plugin/Field/FieldType/ApiKeyItem.php | 87 +++++ .../Plugin/Field/FieldWidget/ApiKeyWidget.php | 43 +++ .../ContentProcessor/Report.php | 28 +- .../src/ProviderAccessControlHandler.php | 14 + .../src/ProviderListBuilder.php | 70 ++++ .../src/ProviderStorage.php | 14 + .../src/ProviderStorageInterface.php | 14 + .../src/Routing/ProviderHtmlRouteProvider.php | 14 + .../src/Services/ProviderManager.php | 44 --- .../src/Services/ProviderManagerInterface.php | 25 -- .../src/Theme/ThemeNegotiator.php | 33 ++ .../tests/data/data-report.json | 7 +- .../Attribute/ContentProcessorTest.php | 3 +- .../Controller/ReliefWebPostApiTest.php | 152 ++++++-- .../src/ExistingSite/Entity/ProviderTest.php | 158 ++++++-- .../ExistingSite/Form/ProviderFormTest.php | 102 +++++ .../Plugin/ContentProcessorPluginBaseTest.php | 358 +++++++++++++++--- .../ContentProcessorPluginManagerTest.php | 28 ++ .../FieldFormatter/ApiKeyFormatterTest.php | 47 +++ .../Plugin/Field/FieldType/ApiKeyItemTest.php | 88 +++++ .../Field/FieldWidget/ApiKeyWidgetTest.php | 64 ++++ .../ContentProcessor/ReportTest.php | 47 ++- .../ExistingSite/ProviderListBuilderTest.php | 52 +++ .../Services/ProviderManagerTest.php | 43 --- .../Theme/ThemeNegotiatorTest.php | 44 +++ 58 files changed, 2407 insertions(+), 364 deletions(-) create mode 100644 config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml create mode 100644 config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml create mode 100644 config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_document_url.yml create mode 100644 config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url.yml create mode 100644 config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url.yml create mode 100644 config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify.yml create mode 100644 config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_source.yml create mode 100644 config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_user.yml create mode 100644 config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_webhook_url.yml create mode 100644 config/field.storage.reliefweb_post_api_provider.field_document_url.yml create mode 100644 config/field.storage.reliefweb_post_api_provider.field_file_url.yml create mode 100644 config/field.storage.reliefweb_post_api_provider.field_image_url.yml create mode 100644 config/field.storage.reliefweb_post_api_provider.field_notify.yml create mode 100644 config/field.storage.reliefweb_post_api_provider.field_source.yml create mode 100644 config/field.storage.reliefweb_post_api_provider.field_user.yml create mode 100644 config/field.storage.reliefweb_post_api_provider.field_webhook_url.yml create mode 100644 html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.action.yml create mode 100644 html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.menu.yml create mode 100644 html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.task.yml create mode 100644 html/modules/custom/reliefweb_post_api/reliefweb_post_api.permissions.yml create mode 100644 html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldFormatter/ApiKeyFormatter.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldType/ApiKeyItem.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldWidget/ApiKeyWidget.php create mode 100644 html/modules/custom/reliefweb_post_api/src/ProviderAccessControlHandler.php create mode 100644 html/modules/custom/reliefweb_post_api/src/ProviderListBuilder.php create mode 100644 html/modules/custom/reliefweb_post_api/src/ProviderStorage.php create mode 100644 html/modules/custom/reliefweb_post_api/src/ProviderStorageInterface.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Routing/ProviderHtmlRouteProvider.php delete mode 100644 html/modules/custom/reliefweb_post_api/src/Services/ProviderManager.php delete mode 100644 html/modules/custom/reliefweb_post_api/src/Services/ProviderManagerInterface.php create mode 100644 html/modules/custom/reliefweb_post_api/src/Theme/ThemeNegotiator.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Form/ProviderFormTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldFormatter/ApiKeyFormatterTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldType/ApiKeyItemTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldWidget/ApiKeyWidgetTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/ProviderListBuilderTest.php delete mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Services/ProviderManagerTest.php create mode 100644 html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Theme/ThemeNegotiatorTest.php diff --git a/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml b/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml new file mode 100644 index 000000000..bd0dcea5e --- /dev/null +++ b/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml @@ -0,0 +1,143 @@ +uuid: 15416736-1f4f-421a-ac0d-b677b0e5fcf3 +langcode: en +status: true +dependencies: + config: + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_document_url + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_source + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_user + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_webhook_url + module: + - link + - reliefweb_fields + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.default +targetEntityType: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +mode: default +content: + field_document_url: + type: link_default + weight: 5 + region: content + settings: + placeholder_url: '' + placeholder_title: '' + third_party_settings: { } + field_file_url: + type: link_default + weight: 7 + region: content + settings: + placeholder_url: '' + placeholder_title: '' + third_party_settings: { } + field_image_url: + type: link_default + weight: 6 + region: content + settings: + placeholder_url: '' + placeholder_title: '' + third_party_settings: { } + field_notify: + type: email_default + weight: 8 + region: content + settings: + placeholder: '' + size: 60 + third_party_settings: { } + field_source: + type: reliefweb_entity_reference_select + weight: 2 + region: content + settings: + sort: label + extra_data: + 'source:field_shortname': 'source:field_shortname' + 'source:tid': '0' + 'source:uuid': '0' + 'source:revision_id': '0' + 'source:langcode': '0' + 'source:vid': '0' + 'source:revision_created': '0' + 'source:revision_user': '0' + 'source:revision_log_message': '0' + 'source:status': '0' + 'source:name': '0' + 'source:description': '0' + 'source:weight': '0' + 'source:parent': '0' + 'source:changed': '0' + 'source:default_langcode': '0' + 'source:revision_default': '0' + 'source:revision_translation_affected': '0' + 'source:created': '0' + 'source:moderation_status': '0' + 'source:field_aliases': '0' + 'source:field_allowed_content_types': '0' + 'source:field_attention_job': '0' + 'source:field_attention_report': '0' + 'source:field_attention_training': '0' + 'source:field_country': '0' + 'source:field_disclaimer': '0' + 'source:field_fts_id': '0' + 'source:field_homepage': '0' + 'source:field_job_import_feed': '0' + 'source:field_links': '0' + 'source:field_logo': '0' + 'source:field_longname': '0' + 'source:field_organization_type': '0' + 'source:field_spanish_name': '0' + 'source:field_user_posting_rights': '0' + third_party_settings: { } + field_user: + type: entity_reference_autocomplete + weight: 3 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + field_webhook_url: + type: link_default + weight: 9 + region: content + settings: + placeholder_url: '' + placeholder_title: '' + third_party_settings: { } + key: + type: reliefweb_post_api_key + weight: 4 + region: content + settings: { } + third_party_settings: { } + name: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + resource: + type: options_select + weight: 1 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 10 + region: content + settings: + display_label: true + third_party_settings: { } +hidden: { } diff --git a/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml b/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml new file mode 100644 index 000000000..84b7dfd2e --- /dev/null +++ b/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml @@ -0,0 +1,110 @@ +uuid: 52e1783a-701e-44a9-aa5a-1cbdbee86d09 +langcode: en +status: true +dependencies: + config: + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_document_url + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_source + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_user + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_webhook_url + module: + - link + - options + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.default +targetEntityType: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +mode: default +content: + field_document_url: + type: link + label: above + settings: + trim_length: 80 + url_only: false + url_plain: false + rel: '' + target: '' + third_party_settings: { } + weight: 4 + region: content + field_file_url: + type: link + label: above + settings: + trim_length: 80 + url_only: false + url_plain: false + rel: '' + target: '' + third_party_settings: { } + weight: 6 + region: content + field_image_url: + type: link + label: above + settings: + trim_length: 80 + url_only: false + url_plain: false + rel: '' + target: '' + third_party_settings: { } + weight: 5 + region: content + field_notify: + type: basic_string + label: above + settings: { } + third_party_settings: { } + weight: 7 + region: content + field_source: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 2 + region: content + field_user: + type: entity_reference_label + label: above + settings: + link: true + third_party_settings: { } + weight: 3 + region: content + field_webhook_url: + type: link + label: above + settings: + trim_length: 80 + url_only: false + url_plain: false + rel: '' + target: '' + third_party_settings: { } + weight: 8 + region: content + name: + type: string + label: hidden + settings: + link_to_entity: false + third_party_settings: { } + weight: 0 + region: content + resource: + type: list_default + label: above + settings: { } + third_party_settings: { } + weight: 1 + region: content +hidden: + key: true + status: true diff --git a/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_document_url.yml b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_document_url.yml new file mode 100644 index 000000000..9ad1dbc6c --- /dev/null +++ b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_document_url.yml @@ -0,0 +1,23 @@ +uuid: ae98e3ee-5084-412b-bf82-29612d0dcd68 +langcode: en +status: true +dependencies: + config: + - field.storage.reliefweb_post_api_provider.field_document_url + module: + - link + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.field_document_url +field_name: field_document_url +entity_type: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +label: 'Base document URL' +description: 'Allowed base document URLs.' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + title: 0 + link_type: 16 +field_type: link diff --git a/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url.yml b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url.yml new file mode 100644 index 000000000..e637a2f0a --- /dev/null +++ b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url.yml @@ -0,0 +1,23 @@ +uuid: 39e3ea73-a025-4de8-b009-956e0be2938d +langcode: en +status: true +dependencies: + config: + - field.storage.reliefweb_post_api_provider.field_file_url + module: + - link + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url +field_name: field_file_url +entity_type: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +label: 'Base file URL' +description: 'Allowed base file URLs.' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + title: 0 + link_type: 16 +field_type: link diff --git a/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url.yml b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url.yml new file mode 100644 index 000000000..3ca8b847f --- /dev/null +++ b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url.yml @@ -0,0 +1,23 @@ +uuid: 275b6221-05ba-446a-abfc-758a0a765fb9 +langcode: en +status: true +dependencies: + config: + - field.storage.reliefweb_post_api_provider.field_image_url + module: + - link + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url +field_name: field_image_url +entity_type: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +label: 'Base image URL' +description: 'Allowed base image URLs.' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + title: 0 + link_type: 16 +field_type: link diff --git a/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify.yml b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify.yml new file mode 100644 index 000000000..9e5a59c4d --- /dev/null +++ b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify.yml @@ -0,0 +1,20 @@ +uuid: 000755d1-44d5-4f08-a094-90d8e2f7f301 +langcode: en +status: true +dependencies: + config: + - field.storage.reliefweb_post_api_provider.field_notify + module: + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify +field_name: field_notify +entity_type: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +label: 'Notification emails' +description: 'Email addresses to notify when a document is published.' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: email diff --git a/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_source.yml b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_source.yml new file mode 100644 index 000000000..ab78fdf4d --- /dev/null +++ b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_source.yml @@ -0,0 +1,30 @@ +uuid: 7ea93d46-1289-4858-8d74-3b0c8409bee5 +langcode: en +status: true +dependencies: + config: + - field.storage.reliefweb_post_api_provider.field_source + - taxonomy.vocabulary.source + module: + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.field_source +field_name: field_source +entity_type: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +label: 'Allowed source(s)' +description: 'Sources that the provider is allowed to post for.' +required: true +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + source: source + sort: + field: name + direction: asc + auto_create: false + auto_create_bundle: '' +field_type: entity_reference diff --git a/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_user.yml b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_user.yml new file mode 100644 index 000000000..b85a753c2 --- /dev/null +++ b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_user.yml @@ -0,0 +1,34 @@ +uuid: 5ed037f7-ffc0-42f4-895a-8671d85c2f1e +langcode: en +status: true +dependencies: + config: + - field.storage.reliefweb_post_api_provider.field_user + content: + - 'user:user:c2c23306-3da1-4ec0-90c7-c96bf89f3b98' + module: + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.field_user +field_name: field_user +entity_type: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +label: User +description: 'Owner of the created documents for the provider.' +required: true +translatable: false +default_value: + - + target_uuid: c2c23306-3da1-4ec0-90c7-c96bf89f3b98 +default_value_callback: '' +settings: + handler: 'default:user' + handler_settings: + target_bundles: null + sort: + field: name + direction: ASC + auto_create: false + filter: + type: _none + include_anonymous: false +field_type: entity_reference diff --git a/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_webhook_url.yml b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_webhook_url.yml new file mode 100644 index 000000000..4076ae38d --- /dev/null +++ b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_webhook_url.yml @@ -0,0 +1,23 @@ +uuid: 9271fcf7-3f86-4b9e-88fa-76150089099f +langcode: en +status: true +dependencies: + config: + - field.storage.reliefweb_post_api_provider.field_webhook_url + module: + - link + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.field_webhook_url +field_name: field_webhook_url +entity_type: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +label: 'Webhook URL' +description: 'URL(s) to call with simple information (ex: URL) of a created/updated document.' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + title: 0 + link_type: 16 +field_type: link diff --git a/config/field.storage.reliefweb_post_api_provider.field_document_url.yml b/config/field.storage.reliefweb_post_api_provider.field_document_url.yml new file mode 100644 index 000000000..13a434172 --- /dev/null +++ b/config/field.storage.reliefweb_post_api_provider.field_document_url.yml @@ -0,0 +1,19 @@ +uuid: 229245d7-af72-410a-828e-0a8d22bcd90a +langcode: en +status: true +dependencies: + module: + - link + - reliefweb_post_api +id: reliefweb_post_api_provider.field_document_url +field_name: field_document_url +entity_type: reliefweb_post_api_provider +type: link +settings: { } +module: link +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/field.storage.reliefweb_post_api_provider.field_file_url.yml b/config/field.storage.reliefweb_post_api_provider.field_file_url.yml new file mode 100644 index 000000000..826a68c4b --- /dev/null +++ b/config/field.storage.reliefweb_post_api_provider.field_file_url.yml @@ -0,0 +1,19 @@ +uuid: 11e0f965-d260-4918-b81f-5d9f61d33a97 +langcode: en +status: true +dependencies: + module: + - link + - reliefweb_post_api +id: reliefweb_post_api_provider.field_file_url +field_name: field_file_url +entity_type: reliefweb_post_api_provider +type: link +settings: { } +module: link +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/field.storage.reliefweb_post_api_provider.field_image_url.yml b/config/field.storage.reliefweb_post_api_provider.field_image_url.yml new file mode 100644 index 000000000..598742f18 --- /dev/null +++ b/config/field.storage.reliefweb_post_api_provider.field_image_url.yml @@ -0,0 +1,19 @@ +uuid: 06f080b2-9d92-499e-8aed-615ca77a61c8 +langcode: en +status: true +dependencies: + module: + - link + - reliefweb_post_api +id: reliefweb_post_api_provider.field_image_url +field_name: field_image_url +entity_type: reliefweb_post_api_provider +type: link +settings: { } +module: link +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/field.storage.reliefweb_post_api_provider.field_notify.yml b/config/field.storage.reliefweb_post_api_provider.field_notify.yml new file mode 100644 index 000000000..d9c455ebd --- /dev/null +++ b/config/field.storage.reliefweb_post_api_provider.field_notify.yml @@ -0,0 +1,18 @@ +uuid: 9e96163f-f82f-467f-8711-41e94740252a +langcode: en +status: true +dependencies: + module: + - reliefweb_post_api +id: reliefweb_post_api_provider.field_notify +field_name: field_notify +entity_type: reliefweb_post_api_provider +type: email +settings: { } +module: core +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/field.storage.reliefweb_post_api_provider.field_source.yml b/config/field.storage.reliefweb_post_api_provider.field_source.yml new file mode 100644 index 000000000..21cb34932 --- /dev/null +++ b/config/field.storage.reliefweb_post_api_provider.field_source.yml @@ -0,0 +1,20 @@ +uuid: c30134fe-0960-4547-8da8-91f99a2fd066 +langcode: en +status: true +dependencies: + module: + - reliefweb_post_api + - taxonomy +id: reliefweb_post_api_provider.field_source +field_name: field_source +entity_type: reliefweb_post_api_provider +type: entity_reference +settings: + target_type: taxonomy_term +module: core +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/field.storage.reliefweb_post_api_provider.field_user.yml b/config/field.storage.reliefweb_post_api_provider.field_user.yml new file mode 100644 index 000000000..37122af9f --- /dev/null +++ b/config/field.storage.reliefweb_post_api_provider.field_user.yml @@ -0,0 +1,20 @@ +uuid: 5ecb0f1f-42e2-49c2-b45b-aceb98765cb2 +langcode: en +status: true +dependencies: + module: + - reliefweb_post_api + - user +id: reliefweb_post_api_provider.field_user +field_name: field_user +entity_type: reliefweb_post_api_provider +type: entity_reference +settings: + target_type: user +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/field.storage.reliefweb_post_api_provider.field_webhook_url.yml b/config/field.storage.reliefweb_post_api_provider.field_webhook_url.yml new file mode 100644 index 000000000..41745c623 --- /dev/null +++ b/config/field.storage.reliefweb_post_api_provider.field_webhook_url.yml @@ -0,0 +1,19 @@ +uuid: e739ac53-b6e5-4e05-aba8-570455069f3b +langcode: en +status: true +dependencies: + module: + - link + - reliefweb_post_api +id: reliefweb_post_api_provider.field_webhook_url +field_name: field_webhook_url +entity_type: reliefweb_post_api_provider +type: link +settings: { } +module: link +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.action.yml b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.action.yml new file mode 100644 index 000000000..ec132e3e0 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.action.yml @@ -0,0 +1,5 @@ +reliefweb_post_api_provider.add: + route_name: entity.reliefweb_post_api_provider.add_form + title: 'Add provider' + appears_on: + - entity.reliefweb_post_api_provider.collection diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.menu.yml b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.menu.yml new file mode 100644 index 000000000..1bef430a5 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.menu.yml @@ -0,0 +1,6 @@ +entity.reliefweb_post_api_provider.collection: + title: 'ReliefWeb POST API providers' + route_name: entity.reliefweb_post_api_provider.collection + description: 'List of ReliefWeb POST API provider entities.' + parent: system.admin_structure + weight: 110 diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.task.yml b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.task.yml new file mode 100644 index 000000000..2b7d8a7a0 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.links.task.yml @@ -0,0 +1,17 @@ +entity.reliefweb_post_api_provider.collection: + title: List + route_name: entity.reliefweb_post_api_provider.collection + base_route: entity.reliefweb_post_api_provider.collection + +entity.reliefweb_post_api_provider.canonical: + title: View + route_name: entity.reliefweb_post_api_provider.canonical + base_route: entity.reliefweb_post_api_provider.canonical +entity.reliefweb_post_api_provider.edit_form: + title: Edit + route_name: entity.reliefweb_post_api_provider.edit_form + base_route: entity.reliefweb_post_api_provider.canonical +entity.reliefweb_post_api_provider.delete_form: + title: Delete + route_name: entity.reliefweb_post_api_provider.delete_form + base_route: entity.reliefweb_post_api_provider.canonical diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.permissions.yml b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.permissions.yml new file mode 100644 index 000000000..66332b836 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.permissions.yml @@ -0,0 +1,4 @@ +administer reliefweb post api providers: + title: 'Administer ReliefWeb POST API providers' + description: 'Administer ReliefWeb POST API providers.' + restrict access: true diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml index 576ab4ac1..f8af70cf5 100644 --- a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml @@ -1,6 +1,17 @@ -example: - path: '/post/{bundle}' +reliefweb_post_api.post: + path: '/api/v2/{resource}/{uuid}' defaults: _controller: '\Drupal\reliefweb_post_api\Controller\ReliefWebPostApi::postContent' + # The controller validates the supported methods. + methods: + - HEAD + - OPTIONS + - GET + - POST + - PATCH + - PUT + - DELETE requirements: _access: 'TRUE' + resource: "[a-z]+" + uuid: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.services.yml b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.services.yml index aae627b09..000a7f9cd 100644 --- a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.services.yml +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.services.yml @@ -2,6 +2,7 @@ services: plugin.manager.reliefweb_post_api.content_processor: class: Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManager parent: default_plugin_manager - reliefweb_post_api.provider.manager: - class: Drupal\reliefweb_post_api\Services\ProviderManager - arguments: [] + theme.negotiator.reliefweb_post_api: + class: Drupal\reliefweb_post_api\Theme\ThemeNegotiator + tags: + - { name: theme_negotiator, priority: 0 } diff --git a/html/modules/custom/reliefweb_post_api/schemas/report.json b/html/modules/custom/reliefweb_post_api/schemas/report.json index 064fbbc42..bd8d7dc59 100644 --- a/html/modules/custom/reliefweb_post_api/schemas/report.json +++ b/html/modules/custom/reliefweb_post_api/schemas/report.json @@ -10,6 +10,11 @@ "format": "uri", "maxLength": 2048 }, + "uuid": { + "description": "The Universally unique identifier (UUID) version 5 generated from the URL property with the predefined ns:URL namespace.", + "type": "string", + "format": "uuid" + }, "title": { "description": "Document title.", "type": "string", diff --git a/html/modules/custom/reliefweb_post_api/src/Attribute/ContentProcessor.php b/html/modules/custom/reliefweb_post_api/src/Attribute/ContentProcessor.php index 11864835f..95eb81611 100644 --- a/html/modules/custom/reliefweb_post_api/src/Attribute/ContentProcessor.php +++ b/html/modules/custom/reliefweb_post_api/src/Attribute/ContentProcessor.php @@ -22,18 +22,21 @@ class ContentProcessor extends Plugin { * * @param string $id * The plugin ID. - * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $label + * @param \Drupal\Core\StringTranslation\TranslatableMarkup $label * The label of the plugin. * @param string $entityType - * The entity type the plugin can apply to. - * @param string $entityBundle - * The entity bundle the plugin can apply to. + * The entity type the plugin handles. + * @param string $bundle + * The entity bundle the plugin handles. + * @param string $resource + * The API resource the plugin handles. */ public function __construct( public readonly string $id, - public readonly ?TranslatableMarkup $label = NULL, + public readonly TranslatableMarkup $label, public readonly string $entityType, - public readonly string $entityBundle + public readonly string $bundle, + public readonly string $resource ) {} } diff --git a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php index 42b1be6bc..8bafc5ecd 100644 --- a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php +++ b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php @@ -7,7 +7,6 @@ use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Queue\QueueFactory; use Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface; -use Drupal\reliefweb_post_api\Services\ProviderManager; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RequestStack; @@ -16,6 +15,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Uid\Uuid; /** * Controller for the POST API. @@ -31,14 +31,11 @@ class ReliefWebPostApi extends ControllerBase { * The queue factory. * @param \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface $contentProcessorPluginManager * The ReliefWeb POST API content processor plugin manager. - * @param \Drupal\reliefweb_post_api\Services\ProviderManager $providerManager - * The ReliefWeb POST API provider manager. */ public function __construct( protected RequestStack $requestStack, protected QueueFactory $queueFactory, - protected ContentProcessorPluginManagerInterface $contentProcessorPluginManager, - protected ProviderManager $providerManager + protected ContentProcessorPluginManagerInterface $contentProcessorPluginManager ) {} /** @@ -48,50 +45,62 @@ public static function create(ContainerInterface $container) { return new static( $container->get('request_stack'), $container->get('queue'), - $container->get('plugin.manager.reliefweb_post_api.content_processor'), - $container->get('reliefweb_post_api.provider.manager') + $container->get('plugin.manager.reliefweb_post_api.content_processor') ); } /** * POST endpoint. * - * @param string $bundle - * Entity bundle. + * @param string $resource + * Content resource (ex: reports). + * @param string $uuid + * UUID of the resource to create or update. * * @return \Symfony\Component\HttpFoundation\JsonResponse * The response: 200, 4xx or 5xx */ - public function postContent(string $bundle): JsonResponse { + public function postContent(string $resource, string $uuid): JsonResponse { try { $request = $this->requestStack->getCurrentRequest(); $headers = $request->headers; - // Only POST requests are allowed. - if ($request->getMethod() !== 'POST') { - throw new MethodNotAllowedHttpException(['POST'], 'Unsupported method.'); + // Only PUT requests are allowed currently. + // @todo handle PATCH and DELETE. + if ($request->getMethod() !== 'PUT') { + throw new MethodNotAllowedHttpException(['PUT'], 'Unsupported method.'); } - // Retrieve the provider. - $provider = $this->providerManager->getProvider($headers->get('X-RW-POST-API-PROVIDER', '')); - if (!isset($provider)) { - throw new AccessDeniedHttpException('Invalid provider.'); + // Validate the endpoint syntax. + if (preg_match('/^[a-z_-]+$/', $resource) !== 1) { + throw new NotFoundHttpException('Invalid endpoint resource.'); } - - // Check access. - if (!$provider->validateKey($headers->get('X-RW-POST-API-KEY', ''))) { - throw new AccessDeniedHttpException('Invalid API key.'); + if (!Uuid::isValid($uuid)) { + throw new NotFoundHttpException('Invalid endpoint UUID.'); } - // Check if the bundle is supported. + // Check if the resource is supported. try { - $plugin = $this->contentProcessorPluginManager->getPluginByBundle($bundle); + $plugin = $this->contentProcessorPluginManager->getPluginByResource($resource); if (empty($plugin)) { throw new \Exception(); } } catch (\Exception $exception) { - throw new NotFoundHttpException('Invalid endpoint.'); + throw new NotFoundHttpException('Unknown endpoint.'); + } + + // Retrieve the provider. + try { + $provider = $plugin->getProvider($headers->get('X-RW-POST-API-PROVIDER', '')); + } + catch (\Exception $exception) { + throw new AccessDeniedHttpException('Invalid provider.'); + } + + // Check access. + if (!$provider->validateKey($headers->get('X-RW-POST-API-KEY', ''))) { + throw new AccessDeniedHttpException('Invalid API key.'); } // Check if we received JSON data. @@ -114,13 +123,16 @@ public function postContent(string $bundle): JsonResponse { throw new BadRequestHttpException('Invalid JSON body.'); } + // Add the UUID if not already in the payload. + $data['uuid'] = $data['uuid'] ?? $uuid; + // Add the bundle to the data so we can know which plugin to use when // retrieve and processing it. - $data['bundle'] = $bundle; + $data['bundle'] = $plugin->getBundle(); // Add the provider ID so we can perform additional checks like verifying // the URLs of attachments. - $data['provider'] = $provider->id(); + $data['provider'] = $provider->uuid(); // Validate the content against the schema for the bundle. try { diff --git a/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php b/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php index 1decef542..25499ce10 100644 --- a/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php +++ b/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php @@ -4,64 +4,213 @@ namespace Drupal\reliefweb_post_api\Entity; +use Drupal\Core\Entity\ContentEntityBase; +use Drupal\Core\Entity\EntityChangedTrait; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Field\BaseFieldDefinition; + /** * Defines a provider entity. * - * @todo this is currently a real entity and simply implements the - * ProviderInterface interface with data retrieved from the site settings. - * This could become a real entity if needed. + * @ContentEntityType( + * id = "reliefweb_post_api_provider", + * label = @Translation("ReliefWeb POST API provider"), + * label_collection = @Translation("ReliefWeb POST API providers"), + * label_singular = @Translation("ReliefWeb POST API provider"), + * label_plural = @Translation("ReliefWeb POST API providers"), + * label_count = @PluralTranslation( + * singular = "@count ReliefWeb POST API provider", + * plural = "@count ReliefWeb POST API providers" + * ), + * handlers = { + * "storage" = "Drupal\reliefweb_post_api\ProviderStorage", + * "access" = "Drupal\Core\Entity\EntityAccessControlHandler", + * "view_builder" = "Drupal\Core\Entity\EntityViewBuilder", + * "list_builder" = "Drupal\reliefweb_post_api\ProviderListBuilder", + * "views_data" = "Drupal\views\EntityViewsData", + * "form" = { + * "default" = "Drupal\reliefweb_post_api\Form\ProviderForm", + * "add" = "Drupal\reliefweb_post_api\Form\ProviderForm", + * "edit" = "Drupal\reliefweb_post_api\Form\ProviderForm", + * "delete" = "Drupal\Core\Entity\ContentEntityDeleteForm" + * }, + * "route_provider" = { + * "default" = "Drupal\reliefweb_post_api\Routing\ProviderHtmlRouteProvider" + * } + * }, + * base_table = "reliefweb_post_api_provider", + * admin_permission = "administer reliefweb post api providers", + * translatable = FALSE, + * fieldable = TRUE, + * entity_keys = { + * "id" = "id", + * "uuid" = "uuid", + * "label" = "name" + * }, + * links = { + * "canonical" = "/reliefweb-post-api-provider/{reliefweb_post_api_provider}", + * "add-form" = "/reliefweb-post-api-provider/add", + * "edit-form" = "/reliefweb-post-api-provider/{reliefweb_post_api_provider}/edit", + * "delete-form" = "/reliefweb-post-api-provider/{reliefweb_post_api_provider}/delete", + * "collection" = "/admin/content/reliefweb-post-api-providers", + * }, + * field_ui_base_route = "entity.reliefweb_post_api_provider.collection" + * ) */ -class Provider implements ProviderInterface { +class Provider extends ContentEntityBase implements ProviderInterface { - /** - * Constructor. - * - * @param array $data - * The provider data from the settings. - */ - public function __construct(protected array $data) { - } + use EntityChangedTrait; /** * {@inheritdoc} */ - public function id(): string { - return $this->data['id'] ?? ''; + public static function baseFieldDefinitions(EntityTypeInterface $entity_type): array { + /** @var \Drupal\Core\Field\BaseFieldDefinition[] $fields */ + $fields = parent::baseFieldDefinitions($entity_type); + + $fields['name'] = BaseFieldDefinition::create('string') + ->setLabel('Name') + ->setDescription('The provider name.') + ->setRequired(TRUE) + ->setSettings([ + 'default_value' => '', + 'max_length' => 255, + ]) + ->setDisplayOptions('form', [ + 'type' => 'string_textfield', + 'weight' => 0, + ]) + ->setDisplayOptions('view', [ + 'label' => 'hidden', + 'type' => 'string', + ]) + ->setDisplayConfigurable('view', TRUE) + ->setDisplayConfigurable('form', TRUE); + + $fields['key'] = BaseFieldDefinition::create('reliefweb_post_api_key') + ->setLabel(t('API key')) + ->setDescription(t('The API key of this provider.')) + ->setDisplayConfigurable('view', TRUE) + ->setDisplayConfigurable('form', TRUE); + + $options = []; + $plugins = \Drupal::service('plugin.manager.reliefweb_post_api.content_processor')->getDefinitions(); + foreach ($plugins as $definition) { + $options[$definition['resource']] = (string) $definition['label']; + } + + $fields['resource'] = BaseFieldDefinition::create('list_string') + ->setLabel(t('Resource')) + ->setDescription(t('The type of resource that the provider can submit.')) + ->setRequired(TRUE) + ->setSetting('allowed_values', $options) + ->setDisplayOptions('form', [ + 'type' => 'options_select', + ]) + ->setDisplayOptions('view', [ + 'type' => 'string', + ]) + ->setDisplayConfigurable('view', TRUE) + ->setDisplayConfigurable('form', TRUE); + + $fields['status'] = BaseFieldDefinition::create('boolean') + ->setLabel(t('Active')) + ->setDescription(t('Whether the provider is active or blocked.')) + ->setDefaultValue(FALSE) + ->setDisplayConfigurable('view', TRUE) + ->setDisplayConfigurable('form', TRUE); + + $fields['created'] = BaseFieldDefinition::create('created') + ->setLabel('Created') + ->setDescription('The time when the provider was created.'); + + $fields['changed'] = BaseFieldDefinition::create('changed') + ->setLabel('Changed') + ->setDescription('The time when the provider was last edited.'); + + return $fields; } /** * {@inheritdoc} */ - public function getUrlPattern(): string { - return $this->data['url_pattern'] ?? '#^https://.+$#'; + public function getUrlPattern(string $type = 'document'): string { + $field = 'field_' . $type . '_url'; + $default = '#^https://.+#'; + + if (!$this->hasField($field)) { + return $default; + } + if ($this->get($field)->isEmpty()) { + return $default; + } + $parts = []; + foreach ($this->get($field) as $item) { + if (!empty($item->uri)) { + $parts[] = preg_quote($item->uri); + } + } + return empty($parts) ? $default : '#^(' . implode('|', $parts) . ')#'; } /** * {@inheritdoc} */ - public function getEmailsToNotify(): array { - return $this->data['notify'] ?? []; + public function getAllowedSources(): array { + $field = 'field_source'; + if (!$this->hasField($field)) { + return []; + } + if ($this->get($field)->isEmpty()) { + return []; + } + $sources = []; + foreach ($this->get($field) as $item) { + $sources[$item->target_id] = $item->target_id; + } + return $sources; } /** * {@inheritdoc} */ - public function getAllowedSources(): array { - return $this->data['sources'] ?? []; + public function getEmailsToNotify(): array { + $field = 'field_notify'; + if (!$this->hasField($field)) { + return []; + } + if ($this->get($field)->isEmpty()) { + return []; + } + $emails = []; + foreach ($this->get($field) as $item) { + $emails[] = $item->value; + } + return $emails; } /** * {@inheritdoc} */ public function getUserId(): int { - return $this->data['uid'] ?? 2; + $field = 'field_user'; + if (!$this->hasField($field)) { + return 2; + } + if (empty($this->get($field)->target_id)) { + return 2; + } + return (int) $this->get($field)->target_id; } /** * {@inheritdoc} */ public function validateKey(string $key): bool { - return isset($this->data['key']) && $this->data['key'] === $key; + if (empty($key) || empty($this->key->value)) { + return FALSE; + } + return \Drupal::service('password')->check($key, $this->key->value); } } diff --git a/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php b/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php index 78fe657d2..fcabb507b 100644 --- a/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php +++ b/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php @@ -4,26 +4,23 @@ namespace Drupal\reliefweb_post_api\Entity; +use Drupal\Core\Entity\ContentEntityInterface; + /** * Interface for a POST API provider. */ -interface ProviderInterface { - - /** - * Get the provider ID. - * - * @return string - * ID. - */ - public function id(): string; +interface ProviderInterface extends ContentEntityInterface { /** * Get the URL pattern for the provider. * + * @param string $type + * Type of URL pattern. One of 'document', 'file' or 'image'. + * * @return string * A regex pattern to match URLs against. */ - public function getUrlPattern(): string; + public function getUrlPattern(string $type = 'document'): string; /** * Get the list of sources the provider is allowed to post for. diff --git a/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php b/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php new file mode 100644 index 000000000..d91d533d1 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php @@ -0,0 +1,82 @@ +get('entity.repository'), + $container->get('entity_type.bundle.info'), + $container->get('datetime.time'), + $container->get('password'), + $container->get('plugin.manager.reliefweb_post_api.content_processor') + ); + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + $form = parent::form($form, $form_state); + + $form['#attributes']['data-enhanced'] = ''; + + $form['field_source']['#attributes']['data-with-autocomplete'] = ''; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) : void { + $this->entity->save(); + $this->messenger()->addMessage($this->t('Saved the %label provider.', [ + '%label' => $this->entity->label(), + ])); + $form_state->setRedirect('entity.reliefweb_post_api_provider.collection'); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php index 8c7873a35..d5b84a19c 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php @@ -24,7 +24,6 @@ use Drupal\media\MediaInterface; use Drupal\reliefweb_files\Plugin\Field\FieldType\ReliefWebFile; use Drupal\reliefweb_post_api\Entity\ProviderInterface; -use Drupal\reliefweb_post_api\Services\ProviderManager; use Drupal\reliefweb_utility\Helpers\HtmlSanitizer; use Drupal\reliefweb_utility\Helpers\TextHelper; use GuzzleHttp\ClientInterface; @@ -63,6 +62,13 @@ abstract class ContentProcessorPluginBase extends CorePluginBase implements Cont */ protected string $jsonSchema; + /** + * Static cache for the providers. + * + * @var array + */ + protected array $providers = []; + /** * Constructs a \Drupal\Component\Plugin\PluginBase object. * @@ -92,8 +98,6 @@ abstract class ContentProcessorPluginBase extends CorePluginBase implements Cont * The file mimetype guesser. * @param \Drupal\Core\Language\LanguageManagerInterface $languageManager * The language manager. - * @param \Drupal\reliefweb_post_api\Services\ProviderManager $providerManager - * The ReliefWeb POST API provider manager. */ public function __construct( array $configuration, @@ -108,8 +112,7 @@ public function __construct( protected FileSystemInterface $fileSystem, protected FileValidatorInterface $fileValidator, protected MimeTypeGuesserInterface $mimeTypeGuesser, - protected LanguageManagerInterface $languageManager, - protected ProviderManager $providerManager + protected LanguageManagerInterface $languageManager ) { parent::__construct( $configuration, @@ -135,8 +138,7 @@ public static function create(ContainerInterface $container, array $configuratio $container->get('file_system'), $container->get('file.validator'), $container->get('file.mime_type.guesser'), - $container->get('language_manager'), - $container->get('reliefweb_post_api.provider.manager') + $container->get('language_manager') ); } @@ -158,8 +160,15 @@ public function getEntityType(): string { /** * {@inheritdoc} */ - public function getEntityBundle(): string { - return $this->getPluginDefinition()['entityBundle']; + public function getBundle(): string { + return $this->getPluginDefinition()['bundle']; + } + + /** + * {@inheritdoc} + */ + public function getResource(): string { + return $this->getPluginDefinition()['resource']; } /** @@ -188,7 +197,7 @@ public function getSchemaValidator(): Validator { */ public function getJsonSchema(): string { if (!isset($this->jsonSchema)) { - $bundle = $this->getEntityBundle(); + $bundle = $this->getbundle(); $path = $this->pathResolver->getPath('module', 'reliefweb_post_api'); $schema = @file_get_contents($path . '/schemas/' . $bundle . '.json'); if ($schema === FALSE) { @@ -204,11 +213,23 @@ public function getJsonSchema(): string { /** * {@inheritdoc} */ - public function getProvider(string $id): ProviderInterface { - $provider = $this->providerManager->getProvider($id); - if (!isset($provider)) { + public function getProvider(string $uuid): ProviderInterface { + if (!Uuid::isValid($uuid)) { + throw new ContentProcessorException('Invalid provider UUID.'); + } + if (array_key_exists($uuid, $this->providers)) { + $provider = $this->providers[$uuid]; + } + else { + $provider = $this->entityRepository->loadEntityByUuid('reliefweb_post_api_provider', $uuid); + $this->providers[$uuid] = $provider; + } + if (is_null($provider)) { throw new ContentProcessorException('Invalid provider.'); } + elseif (empty($provider->status->value)) { + throw new ContentProcessorException('Blocked provider.'); + } return $provider; } @@ -222,6 +243,7 @@ abstract public function process(array $data): ?ContentEntityInterface; */ public function validate(array $data): void { $this->validateSchema($data); + $this->validateUuid($data); $this->validateSources($data); $this->validateUrls($data); } @@ -242,6 +264,24 @@ public function validateSchema(array $data): void { } } + /** + * {@inheritdoc} + */ + public function validateUuid(array $data): void { + if (empty($data['url'])) { + throw new ContentProcessorException('Missing document URL.'); + } + elseif (empty($data['uuid'])) { + throw new ContentProcessorException('Missing document UUID.'); + } + elseif (!Uuid::isValid($data['uuid'])) { + throw new ContentProcessorException('Invalid document UUID.'); + } + elseif ($this->generateUuid($data['url']) !== $data['uuid']) { + throw new ContentProcessorException('The UUID does not match the one generated from the URL.'); + } + } + /** * {@inheritdoc} */ @@ -264,24 +304,13 @@ public function validateSources(array $data): void { */ public function validateUrls(array $data): void { $provider = $this->getProvider($data['provider'] ?? ''); - $pattern = $provider->getUrlPattern(); - // Empty pattern means any URL is ok. - if (empty($pattern)) { - return; - } - - if (empty($data['url']) || !$this->validateUrl($data['url'], $pattern)) { - throw new ContentProcessorException('Unallowed document URL: ' . $data['url']); - } - if (!empty($data['image']['url']) && !$this->validateUrl($data['image']['url'], $pattern)) { - throw new ContentProcessorException('Unallowed image URL: ' . $data['image']['url']); + $document_pattern = $provider->getUrlPattern('document'); + if (empty($data['url'])) { + throw new ContentProcessorException('Missing document URL.'); } - - foreach ($data['file'] ?? [] as $file) { - if (!empty($file['url']) && !$this->validateUrl($file['url'], $pattern)) { - throw new ContentProcessorException('Unallowed file URL: ' . $file['url']); - } + elseif (!$this->validateUrl($data['url'], $document_pattern)) { + throw new ContentProcessorException('Unallowed document URL: ' . $data['url']); } } @@ -289,7 +318,8 @@ public function validateUrls(array $data): void { * {@inheritdoc} */ public function validateUrl(string $url, string $pattern): bool { - return preg_match($pattern, $url) === 1; + // An empty pattern means any URL is ok. + return empty($pattern) || preg_match($pattern, $url) === 1; } /** diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginInterface.php b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginInterface.php index e28aafa25..3581c8be7 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginInterface.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginInterface.php @@ -42,7 +42,15 @@ public function getEntityType(): string; * @return string * Entity bundle. */ - public function getEntityBundle(): string; + public function getBundle(): string; + + /** + * Get the API resource handled by this plugin. + * + * @return string + * Entity resource. + */ + public function getResource(): string; /** * Get the plugin logger. diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManager.php b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManager.php index d08ff44c5..5794be7b1 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManager.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManager.php @@ -43,12 +43,13 @@ public function __construct( ) { parent::__construct( 'Plugin/reliefweb_post_api/ContentProcessor', - $namespaces, $module_handler, + $namespaces, + $module_handler, 'Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginInterface', ContentProcessor::class ); $this->alterInfo('reliefweb_post_api_content_processor_info'); - $this->setCacheBackend($cache_backend, 'reliefweb_post_api_content_processor_info'); + $this->setCacheBackend($cache_backend, 'reliefweb_post_api_content_processors'); } /** @@ -69,11 +70,30 @@ public function getPlugin(string $plugin_id): ?ContentProcessorPluginInterface { /** * {@inheritdoc} */ - public function getPluginByBundle(string $bundle): ?ContentProcessorPluginInterface { - if ($bundle === '') { + public function getPluginFromProperty(string $property, string $value): ?ContentProcessorPluginInterface { + if ($value === '') { return NULL; } - return $this->getPlugin('reliefweb_post_api.content_processor.' . $bundle); + foreach ($this->getDefinitions() as $id => $definition) { + if (isset($definition[$property]) && $definition[$property] === $value) { + return $this->getPlugin($id); + } + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getPluginByBundle(string $bundle): ?ContentProcessorPluginInterface { + return $this->getPluginFromProperty('bundle', $bundle); + } + + /** + * {@inheritdoc} + */ + public function getPluginByResource(string $resource): ?ContentProcessorPluginInterface { + return $this->getPluginFromProperty('resource', $resource); } } diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManagerInterface.php b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManagerInterface.php index 95d6a1083..0e6eaf0cf 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManagerInterface.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManagerInterface.php @@ -20,12 +20,39 @@ interface ContentProcessorPluginManagerInterface { */ public function getPlugin(string $plugin_id): ?ContentProcessorPluginInterface; + /** + * Get a plugin matching a property. + * + * @param string $property + * Plugin property. + * @param string $value + * Property value. + * + * @return \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginInterface|null + * An instance of the plugin or NULL if none was found. + */ + public function getPluginFromProperty(string $property, string $value): ?ContentProcessorPluginInterface; + /** * Get a plugin by bundle. * + * @param string $bundle + * Entity bundle. + * * @return \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginInterface|null * An instance of the plugin or NULL if none was found. */ public function getPluginByBundle(string $bundle): ?ContentProcessorPluginInterface; + /** + * Get a plugin by resource. + * + * @param string $resource + * API resource. + * + * @return \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginInterface|null + * An instance of the plugin or NULL if none was found. + */ + public function getPluginByResource(string $resource): ?ContentProcessorPluginInterface; + } diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldFormatter/ApiKeyFormatter.php b/html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldFormatter/ApiKeyFormatter.php new file mode 100644 index 000000000..a05b84106 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldFormatter/ApiKeyFormatter.php @@ -0,0 +1,42 @@ + $item) { + $elements[$delta] = [ + '#type' => 'inline_template', + '#template' => '{{ value|nl2br }}', + '#context' => [ + 'value' => empty($item->value) ? $this->t('API key missing') : $this->t('API key exists'), + ], + ]; + } + + return $elements; + } + +} diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldType/ApiKeyItem.php b/html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldType/ApiKeyItem.php new file mode 100644 index 000000000..f44699afa --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldType/ApiKeyItem.php @@ -0,0 +1,87 @@ +setLabel(new TranslatableMarkup('The hashed API key')) + ->setSetting('case_sensitive', TRUE); + $properties['existing'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('Existing API key')); + $properties['pre_hashed'] = DataDefinition::create('boolean') + ->setLabel(new TranslatableMarkup('Determines if a API key needs hashing')); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public function preSave(): void { + parent::preSave(); + + $entity = $this->getEntity(); + $field = $this->getFieldDefinition()->getName(); + $value = trim($this->value ?? ''); + $original_value = $entity->original?->get($field)->value ?? ''; + + if ($this->pre_hashed) { + // Reset the pre_hashed value since it has now been used. + $this->pre_hashed = FALSE; + } + elseif (empty($value)) { + // If the API key is empty, that means it was not changed, so use the + // original one. + $this->value = $original_value; + } + elseif ($value !== $original_value) { + // Allow alternate hashing schemes. + $this->value = \Drupal::service('password')->hash(trim($value)); + + // Abort if the hashing failed and returned FALSE. + if (!$this->value) { + throw new EntityMalformedException('The entity does not have an API key.'); + } + } + + // Ensure that the existing API key is unset to minimize risks of it + // getting serialized and stored somewhere. + $this->existing = NULL; + } + + /** + * {@inheritdoc} + */ + public function isEmpty(): bool { + // We cannot use the parent implementation from StringItem as it does not + // consider the additional 'existing' property that ApiKeyItem contains. + $value = $this->get('value')->getValue(); + $existing = $this->get('existing')->getValue(); + return $value === NULL && $existing === NULL; + } + +} diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldWidget/ApiKeyWidget.php b/html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldWidget/ApiKeyWidget.php new file mode 100644 index 000000000..7bbd86b09 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/Field/FieldWidget/ApiKeyWidget.php @@ -0,0 +1,43 @@ + 'textfield', + '#default_value' => NULL, + '#size' => $this->getSetting('size'), + '#placeholder' => $this->getSetting('placeholder'), + '#maxlength' => $this->getFieldSetting('max_length'), + ]; + + if ($form_state->getFormObject()?->getEntity()?->isNew() !== TRUE) { + $element['value']['#description'] = $this->t('@description. Leave blank to keep the existing API key.', [ + '@description' => $element['value']['#description'], + ]); + } + + return $element; + } + +} diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php index ae0357dbc..82776ffe7 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php @@ -15,12 +15,34 @@ */ #[ContentProcessor( id: 'reliefweb_post_api.content_processor.report', - label: new TranslatableMarkup('Report content processor'), + label: new TranslatableMarkup('Reports'), entityType: 'node', - entityBundle: 'report' + bundle: 'report', + resource: 'reports' )] class Report extends ContentProcessorPluginBase { + /** + * {@inheritdoc} + */ + public function validateUrls(array $data): void { + parent::validateUrls($data); + + $provider = $this->getProvider($data['provider'] ?? ''); + + $image_pattern = $provider->getUrlPattern('image'); + if (!empty($data['image']['url']) && !$this->validateUrl($data['image']['url'], $image_pattern)) { + throw new ContentProcessorException('Unallowed image URL: ' . $data['image']['url']); + } + + $file_pattern = $provider->getUrlPattern('file'); + foreach ($data['file'] ?? [] as $file) { + if (!empty($file['url']) && !$this->validateUrl($file['url'], $file_pattern)) { + throw new ContentProcessorException('Unallowed file URL: ' . $file['url']); + } + } + } + /** * {@inheritdoc} */ @@ -28,7 +50,7 @@ public function process(array $data): ?ContentEntityInterface { // Ensure the data is valid. $this->validate($data); - $bundle = $this->getEntityBundle(); + $bundle = $this->getbundle(); $provider = $this->getProvider($data['provider'] ?? ''); // Generate the UUID corresponding to the document URL. diff --git a/html/modules/custom/reliefweb_post_api/src/ProviderAccessControlHandler.php b/html/modules/custom/reliefweb_post_api/src/ProviderAccessControlHandler.php new file mode 100644 index 000000000..d73ed6ad8 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/src/ProviderAccessControlHandler.php @@ -0,0 +1,14 @@ +t('Name'); + $header['uuid'] = $this->t('UUID'); + $header['resource'] = $this->t('Resource'); + $header['source'] = $this->t('Sources'); + $header['status'] = $this->t('Active'); + return $header + parent::buildHeader(); + } + + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity): array { + $row['name']['data'] = [ + '#type' => 'link', + '#title' => $entity->label(), + ] + $entity->toUrl()->toRenderArray(); + + $row['uuid']['data'] = $entity->uuid->view([ + 'label' => 'hidden', + ]); + + $row['resource']['data'] = $entity->resource->view([ + 'label' => 'hidden', + ]); + + $sources = []; + foreach ($entity->field_source as $item) { + $source = $item->entity; + if (!empty($source)) { + $sources[] = $this->t('@link (@id)', [ + '@link' => $source->toLink($source->field_shortname?->value ?? $source->label())->toString(), + '@id' => $source->id(), + ]); + } + } + $row['source']['data'] = [ + '#theme' => 'item_list', + '#items' => $sources, + ]; + + $row['status']['data'] = $entity->status->view([ + 'label' => 'hidden', + 'format' => 'yes-no', + 'settings' => [ + 'format' => 'yes-no', + ], + ]); + + return $row + parent::buildRow($entity); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/src/ProviderStorage.php b/html/modules/custom/reliefweb_post_api/src/ProviderStorage.php new file mode 100644 index 000000000..21f45e508 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/src/ProviderStorage.php @@ -0,0 +1,14 @@ +providers)) { - $providers = Settings::get('reliefweb_post_api.providers', []); - if (!empty($providers[$id])) { - $this->providers[$id] = new Provider($providers[$id] + ['id' => $id]); - } - else { - $this->providers[$id] = NULL; - } - } - - return $this->providers[$id]; - } - -} diff --git a/html/modules/custom/reliefweb_post_api/src/Services/ProviderManagerInterface.php b/html/modules/custom/reliefweb_post_api/src/Services/ProviderManagerInterface.php deleted file mode 100644 index c0e1995cc..000000000 --- a/html/modules/custom/reliefweb_post_api/src/Services/ProviderManagerInterface.php +++ /dev/null @@ -1,25 +0,0 @@ -getRouteName(), $routes); + } + + /** + * {@inheritdoc} + */ + public function determineActiveTheme(RouteMatchInterface $route_match) { + return 'common_design_subtheme'; + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/data/data-report.json b/html/modules/custom/reliefweb_post_api/tests/data/data-report.json index 44960b66a..713b6ebbe 100644 --- a/html/modules/custom/reliefweb_post_api/tests/data/data-report.json +++ b/html/modules/custom/reliefweb_post_api/tests/data/data-report.json @@ -1,6 +1,7 @@ { - "url": "https://test.test/test/3", - "title": "This a test document - DOC3", + "url": "https://test.test/test/report", + "uuid": "d791d10c-1bbb-5418-a719-b58ed3b3f043", + "title": "This a test document - report", "source": [1503], "country": [13, 14], "format": [10], @@ -31,5 +32,5 @@ "disaster": [51754], "disaster_type": [4628], "theme": [4587, 4595, 4603], - "origin": "https://test.test/test/3" + "origin": "https://test.test/test/report" } diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Attribute/ContentProcessorTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Attribute/ContentProcessorTest.php index 8b078c56a..5ed08f53d 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Attribute/ContentProcessorTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Attribute/ContentProcessorTest.php @@ -25,7 +25,8 @@ public function testContructor(): void { 'test', new TranslatableMarkup('test processor'), 'test_entity_type', - 'test_entity_bundle', + 'test_bundle', + 'test_resource', ); $this->assertInstanceOf(ContentProcessor::class, $attribute); } diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php index 519295057..43fae9442 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php @@ -6,11 +6,12 @@ use Drupal\Core\Queue\QueueFactory; use Drupal\Core\Queue\QueueInterface; -use Drupal\Core\Site\Settings; use Drupal\reliefweb_post_api\Controller\ReliefWebPostApi; +use Drupal\reliefweb_post_api\Entity\ProviderInterface; use Symfony\Component\HttpFoundation\HeaderBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Uid\Uuid; use weitzman\DrupalTestTraits\ExistingSiteBase; /** @@ -23,28 +24,18 @@ class ReliefWebPostApiTest extends ExistingSiteBase { /** - * Loaded POST API data. + * Test providers. * * @var array */ - protected array $postApiData = []; + protected array $providers; /** - * {@inheritdoc} + * Loaded POST API data. + * + * @var array */ - protected function setUp(): void { - parent::setUp(); - - // Add a test providers. - $settings = Settings::getAll(); - $settings['reliefweb_post_api.providers']['test-provider'] = [ - 'id' => 'test-provider', - 'key' => 'test-provider-key', - 'sources' => [1503], - 'url_pattern' => '#^https://test.test/#', - ]; - new Settings($settings); - } + protected array $postApiData = []; /** * @covers ::__construct @@ -74,7 +65,7 @@ public function testPostContentUnknownException(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(500, $response->getStatusCode()); $this->assertStringContainsString('test exception', $response->getContent()); } @@ -93,7 +84,7 @@ public function testPostContentMethodNotAllowed(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(405, $response->getStatusCode()); $this->assertStringContainsString('Unsupported method.', $response->getContent()); } @@ -112,7 +103,7 @@ public function testPostContentInvalidProvider(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(403, $response->getStatusCode()); $this->assertStringContainsString('Invalid provider.', $response->getContent()); } @@ -131,7 +122,7 @@ public function testPostContentInvalidApiKey(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(403, $response->getStatusCode()); $this->assertStringContainsString('Invalid API key.', $response->getContent()); } @@ -139,7 +130,7 @@ public function testPostContentInvalidApiKey(): void { /** * @covers ::postContent */ - public function testPostContentInvalidEndpoint(): void { + public function testPostContentInvalidEndpointResource(): void { $request = $this->createMockRequest(); $request_stack = $this->createMockRequestStack($request); @@ -148,9 +139,43 @@ public function testPostContentInvalidEndpoint(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('test'); + $response = $controller->postContent('test*test', $this->getTestUuid()); $this->assertSame(404, $response->getStatusCode()); - $this->assertStringContainsString('Invalid endpoint.', $response->getContent()); + $this->assertStringContainsString('Invalid endpoint resource.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentInvalidEndpointUuid(): void { + $request = $this->createMockRequest(); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('reports', 'test'); + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringContainsString('Invalid endpoint UUID.', $response->getContent()); + } + + /** + * @covers ::postContent + */ + public function testPostContentUnknownEndpoint(): void { + $request = $this->createMockRequest(); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('test', $this->getTestUuid()); + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringContainsString('Unknown endpoint.', $response->getContent()); } /** @@ -167,7 +192,7 @@ public function testPostContentInvalidContentFormat(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(400, $response->getStatusCode()); $this->assertStringContainsString('Invalid content format.', $response->getContent()); } @@ -186,7 +211,7 @@ public function testPostContentMissingRequestBody(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(400, $response->getStatusCode()); $this->assertStringContainsString('Missing request body.', $response->getContent()); } @@ -205,7 +230,7 @@ public function testPostContentInvalidRequestBody(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(400, $response->getStatusCode()); $this->assertStringContainsString('Invalid request body.', $response->getContent()); } @@ -224,7 +249,7 @@ public function testPostContentInvalidJsonBody(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(400, $response->getStatusCode()); $this->assertStringContainsString('Invalid JSON body.', $response->getContent()); } @@ -243,7 +268,7 @@ public function testPostContentInvalidData(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(400, $response->getStatusCode()); $this->assertStringContainsString('Invalid data', $response->getContent()); } @@ -266,7 +291,7 @@ public function testPostContentQueueException(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(500, $response->getStatusCode()); $this->assertStringContainsString('Internal server error.', $response->getContent()); } @@ -292,7 +317,7 @@ public function testPostContent(): void { 'request_stack' => $request_stack, ]); - $response = $controller->postContent('report'); + $response = $controller->postContent('reports', $this->getTestUuid()); $this->assertSame(200, $response->getStatusCode()); $this->assertStringContainsString('Document queued for processing.', $response->getContent()); } @@ -310,7 +335,7 @@ public function testPostContent(): void { */ protected function createMockRequest(array $headers = [], array $methods = []): Request { $headers += [ - 'X-RW-POST-API-PROVIDER' => 'test-provider', + 'X-RW-POST-API-PROVIDER' => $this->getTestProvider('test-provider')->uuid(), 'X-RW-POST-API-KEY' => 'test-provider-key', ]; @@ -325,7 +350,7 @@ protected function createMockRequest(array $headers = [], array $methods = []): ->willReturnMap($header_map); $methods += [ - 'getMethod' => 'POST', + 'getMethod' => 'PUT', 'getContentTypeFormat' => 'json', 'getContent' => $this->getPostApiData(), ]; @@ -367,7 +392,6 @@ protected function createTestController(array $services = []): ReliefWebPostApi $services['request_stack'] ?? $container->get('request_stack'), $services['queue'] ?? $container->get('queue'), $services['plugin.manager.reliefweb_post_api.content_processor'] ?? $container->get('plugin.manager.reliefweb_post_api.content_processor'), - $services['reliefweb_post_api.provider.manager'] ?? $container->get('reliefweb_post_api.provider.manager'), ]; return new ReliefWebPostApi(...$services); @@ -378,16 +402,68 @@ protected function createTestController(array $services = []): ReliefWebPostApi * * @param string $bundle * The bundle of the data. + * @param string $type + * The type of data: raw (string) or decoded (array). * - * @return string + * @return string|array * The data as a JSON string. */ - protected function getPostApiData(string $bundle = 'report'): string { + protected function getPostApiData(string $bundle = 'report', string $type = 'raw'): string|array { if (!isset($this->postApiData[$bundle])) { $file = __DIR__ . '/../../../data/data-' . $bundle . '.json'; - $this->postApiData[$bundle] = file_get_contents($file); + $data = file_get_contents($file); + $this->postApiData[$bundle] = [ + 'raw' => $data, + 'decoded' => json_decode($data, TRUE), + ]; + } + return $this->postApiData[$bundle][$type]; + } + + /** + * Get the UUID of test data. + * + * @param string $bundle + * The bundle of the data. + * + * @return string + * UUID. + */ + protected function getTestUuid(string $bundle = 'report'): string { + return $this->getPostApiData('report', 'decoded')['uuid']; + } + + /** + * Get a test provider. + * + * @param string $name + * Provider name. + * + * @return Drupal\reliefweb_post_api\Entity\ProviderInterface|null + * Provider entity. + */ + protected function getTestProvider(string $name = 'test-provider'): ?ProviderInterface { + if (!isset($this->providers)) { + /** @var \Drupal\reliefweb_post_api\EntityProviderInterface $provider */ + $provider = \Drupal::entityTypeManager() + ->getStorage('reliefweb_post_api_provider') + ->create([ + 'id' => 666, + 'name' => 'test-provider', + 'uuid' => Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_URL), 'test-provider')->toRfc4122(), + 'key' => 'test-provider-key', + 'status' => 1, + 'field_source' => [1503], + 'field_user' => 2, + 'field_document_url' => ['https://test.test/'], + 'field_file_url' => ['https://test.test/'], + 'field_image_url' => ['https://test.test/'], + ]); + $provider->save(); + $this->markEntityForCleanup($provider); + $this->providers['test-provider'] = $provider; } - return $this->postApiData[$bundle]; + return $this->providers[$name] ?? NULL; } } diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php index 3a40ac072..03c6055c3 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php @@ -5,6 +5,7 @@ namespace Drupal\Tests\reliefweb_post_api\ExistingSite\Entity; use Drupal\reliefweb_post_api\Entity\Provider; +use Drupal\reliefweb_post_api\Entity\ProviderInterface; use weitzman\DrupalTestTraits\ExistingSiteBase; /** @@ -22,34 +23,24 @@ class ProviderTest extends ExistingSiteBase { * @var array */ protected $data = [ - 'id' => 'test-provider', + 'name' => 'test-provider', 'key' => 'test-provider-key', - 'url_pattern' => '@^https://test.test/@', - 'notify' => ['test@test.test'], - 'sources' => [1503], - 'uid' => 12, + 'field_document_url' => ['https://test.test/', 'https://test1.test/'], + 'field_file_url' => ['https://test.test/'], + 'field_image_url' => ['https://test.test/'], + 'field_notify' => ['test@test.test'], + 'field_source' => [1503], + 'field_user' => 12, ]; /** - * @covers ::__construct + * @covers ::baseFieldDefinitions */ - public function testContructor(): void { - $provider = new Provider($this->data); - $this->assertInstanceOf(Provider::class, $provider); - } - - /** - * @covers ::id - */ - public function testId(): void { - $data = $this->data; + public function testBaseFieldDefinitions(): void { + $entity_type = \Drupal::entityTypeManager()->getDefinition('reliefweb_post_api_provider'); - $provider = new Provider($data); - $this->assertEquals($this->data['id'], $provider->id()); - - unset($data['id']); - $provider = new Provider($data); - $this->assertEquals('', $provider->id()); + $definitions = Provider::baseFieldDefinitions($entity_type); + $this->assertArrayHasKey('resource', $definitions); } /** @@ -58,12 +49,31 @@ public function testId(): void { public function testGetUrlPattern(): void { $data = $this->data; - $provider = new Provider($data); - $this->assertEquals($this->data['url_pattern'], $provider->getUrlPattern()); + $parts = array_map('preg_quote', $data['field_document_url']); + $pattern = '#^(' . implode('|', $parts) . ')#'; + $default = '#^https://.+#'; + + // Normal data. + $provider = $this->createProvider($data); + $this->assertEquals($pattern, $provider->getUrlPattern('document')); + + // Unexisting type. + $this->assertEquals($default, $provider->getUrlPattern('test')); + + // No data. + unset($data['field_document_url']); + $provider = $this->createProvider($data); + $this->assertEquals($default, $provider->getUrlPattern('document')); - unset($data['url_pattern']); - $provider = new Provider($data); - $this->assertEquals('#^https://.+$#', $provider->getUrlPattern()); + // Empty base URLs. + $data['field_document_url'] = ['']; + $provider = $this->createProvider($data); + $this->assertEquals($default, $provider->getUrlPattern('document')); + + // Missing field. + $provider = $this->createNoFieldProvider(); + $this->assertFalse($provider->hasField('field_document_url')); + $this->assertEquals($default, $provider->getUrlPattern('document')); } /** @@ -72,11 +82,20 @@ public function testGetUrlPattern(): void { public function testGetEmailsToNotify(): void { $data = $this->data; - $provider = new Provider($data); - $this->assertEquals($this->data['notify'], $provider->getEmailsToNotify()); + $provider = $this->createProvider($data); + $this->assertEquals($this->data['field_notify'], $provider->getEmailsToNotify()); + + $data['field_notify'] = []; + $provider = $this->createProvider($data); + $this->assertEquals([], $provider->getEmailsToNotify()); + + unset($data['field_notify']); + $provider = $this->createProvider($data); + $this->assertEquals([], $provider->getEmailsToNotify()); - unset($data['notify']); - $provider = new Provider($data); + // Missing field. + $provider = $this->createNoFieldProvider(); + $this->assertFalse($provider->hasField('field_notify')); $this->assertEquals([], $provider->getEmailsToNotify()); } @@ -86,11 +105,20 @@ public function testGetEmailsToNotify(): void { public function testGetAllowedSources(): void { $data = $this->data; - $provider = new Provider($data); - $this->assertEquals($this->data['sources'], $provider->getAllowedSources()); + $provider = $this->createProvider($data); + $this->assertEquals($this->data['field_source'], array_values($provider->getAllowedSources())); + + $data['field_source'] = []; + $provider = $this->createProvider($data); + $this->assertEquals([], $provider->getAllowedSources()); + + unset($data['field_source']); + $provider = $this->createProvider($data); + $this->assertEquals([], $provider->getAllowedSources()); - unset($data['sources']); - $provider = new Provider($data); + // Missing field. + $provider = $this->createNoFieldProvider(); + $this->assertFalse($provider->hasField('field_source')); $this->assertEquals([], $provider->getAllowedSources()); } @@ -100,11 +128,20 @@ public function testGetAllowedSources(): void { public function testGetUserId(): void { $data = $this->data; - $provider = new Provider($data); - $this->assertEquals($this->data['uid'], $provider->getUserId()); + $provider = $this->createProvider($data); + $this->assertEquals($this->data['field_user'], $provider->getUserId()); - unset($data['uid']); - $provider = new Provider($data); + $data['field_user'] = []; + $provider = $this->createProvider($data); + $this->assertEquals(2, $provider->getUserId()); + + unset($data['field_user']); + $provider = $this->createProvider($data); + $this->assertEquals(2, $provider->getUserId()); + + // Missing field. + $provider = $this->createNoFieldProvider(); + $this->assertFalse($provider->hasField('field_user')); $this->assertEquals(2, $provider->getUserId()); } @@ -114,14 +151,53 @@ public function testGetUserId(): void { public function testValidateKey(): void { $data = $this->data; - $provider = new Provider($data); + $provider = $this->createProvider($data); + // The value is only hashed when saved. + $provider->key->first()->preSave(); $this->assertTrue($provider->validateKey($this->data['key'])); $this->assertFalse($provider->validateKey('wrong')); $this->assertFalse($provider->validateKey('')); + // No key. unset($data['key']); - $provider = new Provider($data); + $provider = $this->createProvider($data); $this->assertFalse($provider->validateKey($this->data['key'])); } + /** + * Create a provider. + * + * @param array $data + * Provider data. + * + * @return \Drupal\reliefweb_post_api\Entity\ProviderInterface + * Provider. + */ + protected function createProvider(array $data): ?ProviderInterface { + return \Drupal::entityTypeManager() + ->getStorage('reliefweb_post_api_provider') + ->create($data); + } + + /** + * Create a wrapped provider to test missing fields. + * + * @return \Drupal\reliefweb_post_api\Entity\ProviderInterface + * Provider. + */ + protected function createNoFieldProvider(): ?ProviderInterface { + $entity_type = $bundle = 'reliefweb_post_api_provider'; + + return new class($this->data, $entity_type, $bundle) extends Provider { + + /** + * {@inheritdoc} + */ + public function hasField($field_name) { + return FALSE; + } + + }; + } + } diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Form/ProviderFormTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Form/ProviderFormTest.php new file mode 100644 index 000000000..7579b36b8 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Form/ProviderFormTest.php @@ -0,0 +1,102 @@ +get('entity.repository'), + $container->get('entity_type.bundle.info'), + $container->get('datetime.time'), + $container->get('password'), + $container->get('plugin.manager.reliefweb_post_api.content_processor') + ); + $this->assertInstanceOf(ProviderForm::class, $form_object); + } + + /** + * @covers ::create() + */ + public function testCreate(): void { + $form_object = ProviderForm::create(\Drupal::getContainer()); + $this->assertInstanceOf(ProviderForm::class, $form_object); + } + + /** + * @covers ::form() + */ + public function testForm(): void { + $form_object = \Drupal::entityTypeManager() + ->getFormObject('reliefweb_post_api_provider', 'default') + ->setEntity($this->createDummyProvider()); + + $form_state = new FormState(); + + $form = \Drupal::service('form_builder') + ->buildForm($form_object, $form_state); + + $form = $form_object->form($form, $form_state); + $this->assertArrayHasKey('data-enhanced', $form['#attributes']); + $this->assertArrayHasKey('data-with-autocomplete', $form['field_source']['#attributes']); + } + + /** + * @covers ::save() + */ + public function testSave(): void { + $form_object = \Drupal::entityTypeManager() + ->getFormObject('reliefweb_post_api_provider', 'default') + ->setEntity($this->createDummyProvider()); + + $form_state = new FormState(); + + $form = \Drupal::service('form_builder') + ->buildForm($form_object, $form_state); + + $form_object->save($form, $form_state); + $this->assertSame('entity.reliefweb_post_api_provider.collection', $form_state->getRedirect()->getRouteName()); + } + + /** + * Create a dummy provider. + * + * @return \Drupal\reliefweb_post_api\Entity\ProviderInterface + * Provider. + */ + protected function createDummyProvider(): ?ProviderInterface { + $entity_type = $bundle = 'reliefweb_post_api_provider'; + + return new class([], $entity_type, $bundle) extends Provider { + + /** + * {@inheritdoc} + */ + public function save() { + return NULL; + } + + }; + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php index 6aa2bbad4..4e0f04722 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php @@ -12,7 +12,6 @@ use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\File\FileSystemInterface; -use Drupal\Core\Site\Settings; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\file\Entity\File; use Drupal\file\FileInterface; @@ -20,6 +19,7 @@ use Drupal\reliefweb_files\Plugin\Field\FieldType\ReliefWebFile; use Drupal\reliefweb_post_api\Entity\ProviderInterface; use Drupal\reliefweb_post_api\Plugin\ContentProcessorException; +use Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginBase; use Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginInterface; use Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface; use GuzzleHttp\Client; @@ -30,6 +30,7 @@ use Opis\JsonSchema\Validator; use Psr\Log\LoggerInterface; use Symfony\Component\Mime\MimeTypeGuesserInterface; +use Symfony\Component\Uid\Uuid; use weitzman\DrupalTestTraits\ExistingSiteBase; /** @@ -41,6 +42,13 @@ */ abstract class ContentProcessorPluginBaseTest extends ExistingSiteBase { + /** + * Test providers. + * + * @var array + */ + protected array $providers = []; + /** * Loaded POST API data. * @@ -68,19 +76,49 @@ abstract class ContentProcessorPluginBaseTest extends ExistingSiteBase { protected function setUp(): void { parent::setUp(); - // Add a test providers. - $settings = Settings::getAll(); - $settings['reliefweb_post_api.providers']['test-provider'] = [ - 'key' => 'test-provider-key', - 'sources' => [123], - 'url_pattern' => '#^https://test.test/#', - ]; - $settings['reliefweb_post_api.providers']['test-provider-any'] = [ - 'key' => 'test-provider-any-key', - 'sources' => [], - 'url_pattern' => '', - ]; - new Settings($settings); + $provider = \Drupal::entityTypeManager() + ->getStorage('reliefweb_post_api_provider') + ->create([ + 'id' => 666, + 'name' => 'test-provider', + 'uuid' => $this->getTestProviderUuid('test-provider'), + 'key' => 'test-provider-key', + 'status' => 1, + 'field_source' => [123], + 'field_user' => 2, + 'field_document_url' => ['https://test.test/'], + 'field_file_url' => ['https://test.test/'], + 'field_image_url' => ['https://test.test/'], + ]); + $provider->save(); + $this->markEntityForCleanup($provider); + $this->providers['test-provider'] = $provider; + + $provider_any = \Drupal::entityTypeManager() + ->getStorage('reliefweb_post_api_provider') + ->create([ + 'id' => 667, + 'name' => 'test-provider-any', + 'uuid' => $this->getTestProviderUuid('test-provider-any'), + 'key' => 'test-provider-any-key', + 'status' => 1, + ]); + $provider_any->save(); + $this->markEntityForCleanup($provider_any); + $this->providers['test-provider-any'] = $provider_any; + + $provider_blocked = \Drupal::entityTypeManager() + ->getStorage('reliefweb_post_api_provider') + ->create([ + 'id' => 668, + 'name' => 'test-provider-blocked', + 'uuid' => $this->getTestProviderUuid('test-provider-blocked'), + 'key' => 'test-provider-blocked-key', + 'status' => 0, + ]); + $provider_blocked->save(); + $this->markEntityForCleanup($provider_blocked); + $this->providers['test-provider-blocked'] = $provider_blocked; $this->contentProcessorPluginManager = \Drupal::service('plugin.manager.reliefweb_post_api.content_processor'); } @@ -112,9 +150,9 @@ abstract public function testGetPluginLabel(): void; abstract public function testGetEntityType(): void; /** - * @covers ::getEntityBundle + * @covers ::getBundle */ - abstract public function testGetEntityBundle(): void; + abstract public function testGetBundle(): void; /** * @covers ::getLogger @@ -154,19 +192,55 @@ public function testGetJsonSchemaInvalid(): void { * @covers ::getProvider */ public function testGetProvider(): void { + $plugin = $this->createDummyPlugin(); + // Valid provider. - $provider = $this->plugin->getProvider('test-provider'); + $uuid = $this->getTestProviderUuid('test-provider'); + $provider = $plugin->getProvider($uuid); + $this->assertInstanceOf(ProviderInterface::class, $provider); + + // Test getting provider from static cache. + $provider = $plugin->getProvider($uuid); $this->assertInstanceOf(ProviderInterface::class, $provider); } /** * @covers ::getProvider */ - public function testGetProviderInvalid(): void { - // Invalid provider. + public function testGetProviderInvalidUuid(): void { + $plugin = $this->createDummyPlugin(); + + // Invalid provider UUID. + $uuid = 'invalid'; + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Invalid provider UUID.'); + $plugin->getProvider($uuid); + } + + /** + * @covers ::getProvider + */ + public function testGetProviderBlocked(): void { + $plugin = $this->createDummyPlugin(); + + // Blocked provider. + $uuid = $this->getTestProviderUuid('test-provider-blocked'); $this->expectException(ContentProcessorException::class); - $this->expectExceptionMessage('Invalid provider'); - $this->plugin->getProvider('unknown'); + $this->expectExceptionMessage('Blocked provider.'); + $plugin->getProvider($uuid); + } + + /** + * @covers ::getProvider + */ + public function testGetProviderUnknown(): void { + $plugin = $this->createDummyPlugin(); + + // Unknown provider. + $uuid = $this->getTestProviderUuid('test-provider-unknown'); + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Invalid provider.'); + $plugin->getProvider($uuid); } /** @@ -237,6 +311,69 @@ public function testValidateSchemaInvalid(): void { $this->plugin->validateSchema(['url' => FALSE] + $data); } + /** + * @covers ::validateUuid + */ + public function testValidateUuid(): void { + $plugin = $this->createDummyPlugin(); + $data = ['url' => 'https://test.test']; + $data['uuid'] = $plugin->generateUuid($data['url']); + + // Valid data. + $plugin->validateUuid($data); + $this->assertTrue(TRUE); + } + + /** + * @covers ::validateUuid + */ + public function testValidateUuidMissingUrl(): void { + $plugin = $this->createDummyPlugin(); + $data = []; + + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Missing document URL'); + $plugin->validateUuid($data); + } + + /** + * @covers ::validateUuid + */ + public function testValidateUuidMissingUuid(): void { + $plugin = $this->createDummyPlugin(); + $data = ['url' => 'https://test.test']; + + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Missing document UUID'); + $plugin->validateUuid($data); + } + + /** + * @covers ::validateUuid + */ + public function testValidateUuidInvalidUuid(): void { + $plugin = $this->createDummyPlugin(); + $data = ['url' => 'https://test.test']; + $data['uuid'] = 'abc'; + + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Invalid document UUID'); + $plugin->validateUuid($data); + } + + /** + * @covers ::validateUuid + */ + public function testValidateUuidMismatchingUuid(): void { + $plugin = $this->createDummyPlugin(); + $data = ['url' => 'https://test.test']; + $data['uuid'] = $plugin->generateUuid('test'); + + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('The UUID does not match the one generated from the URL'); + $plugin->validateUuid($data); + } + /** * @covers ::validateSources */ @@ -249,7 +386,7 @@ public function testValidateSources(): void { // Any source allowed. $this->plugin->validateSources([ - 'provider' => 'test-provider-any', + 'provider' => $this->getTestProvider('test-provider-any')->uuid(), 'source' => ['789'], ] + $data); $this->assertTrue(TRUE); @@ -300,14 +437,20 @@ public function testValidateUrls(): void { // Allowed URLs. $this->plugin->validateUrls($data); $this->assertTrue(TRUE); + } - // Any URL allowed. - $this->plugin->validateUrls([ - 'provider' => 'test-provider-any', + /** + * @covers ::validateUrls + */ + public function testValidateUrlsAny(): void { + $data = [ + 'provider' => $this->getTestProviderUuid('test-provider-any'), 'url' => 'https://test-any.test/anything', - ] + $data); - $this->assertTrue(TRUE); + ]; + // Any URL allowed. + $this->plugin->validateUrls($data); + $this->assertTrue(TRUE); } /** @@ -315,17 +458,19 @@ public function testValidateUrls(): void { */ public function testValidateUrlsEmptyDocumentUrl(): void { $data = $this->getPostApiData(); + // Empty URL. $this->expectException(ContentProcessorException::class); - $this->expectExceptionMessage('Unallowed document URL'); + $this->expectExceptionMessage('Missing document URL'); $this->plugin->validateUrls(['url' => ''] + $data); } /** * @covers ::validateUrls */ - public function testValidateUrlsUnallowedDocuemtnUrl(): void { + public function testValidateUrlsUnallowedDocumentUrl(): void { $data = $this->getPostApiData(); + // Unallowed URL. $this->expectException(ContentProcessorException::class); $this->expectExceptionMessage('Unallowed document URL'); @@ -333,29 +478,64 @@ public function testValidateUrlsUnallowedDocuemtnUrl(): void { } /** - * @covers ::validateUrls + * @covers \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginBase::validateUrls */ - public function testValidateUrlsUnallowedImageUrl(): void { - $data = $this->getPostApiData(); - $data['image']['url'] = 'https://wrong.test/test.jpg'; + public function testValidateUrlsBase(): void { + $plugin = $this->createDummyPlugin(use_plugin_class: FALSE); + $data = [ + 'provider' => $this->getTestProviderUuid('test-provider'), + 'url' => 'https://test.test/anything', + ]; + + // Allowed URLs. + $plugin->validateUrls($data); + $this->assertTrue(TRUE); + } + + /** + * @covers \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginBase::validateUrls + */ + public function testValidateUrlsBaseAny(): void { + $plugin = $this->createDummyPlugin(use_plugin_class: FALSE); + $data = [ + 'provider' => $this->getTestProviderUuid('test-provider-any'), + 'url' => 'https://test-any.test/anything', + ]; + + // Allowed URLs. + $plugin->validateUrls($data); + $this->assertTrue(TRUE); + } + + /** + * @covers \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginBase::validateUrls + */ + public function testValidateUrlsBaseEmptyDocumentUrl(): void { + $plugin = $this->createDummyPlugin(use_plugin_class: FALSE); + $data = [ + 'provider' => $this->getTestProviderUuid('test-provider-any'), + ]; - // Unallowed image URL. + // Empty URL. $this->expectException(ContentProcessorException::class); - $this->expectExceptionMessage('Unallowed image URL'); - $this->plugin->validateUrls($data); + $this->expectExceptionMessage('Missing document URL'); + $plugin->validateUrls($data); } /** - * @covers ::validateUrls + * @covers \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginBase::validateUrls */ - public function testValidateUrlsUnallowedFileUrl(): void { - $data = $this->getPostApiData(); - $data['file'][0]['url'] = 'https://wrong.test/test.pdf'; + public function testValidateUrlsBaseUnallowedDocumentUrl(): void { + $plugin = $this->createDummyPlugin(use_plugin_class: FALSE); + $data = [ + 'provider' => $this->getTestProviderUuid('test-provider'), + 'url' => 'https://wrong.test/', + ]; - // Unallowed file URL. + // Unallowed URL. $this->expectException(ContentProcessorException::class); - $this->expectExceptionMessage('Unallowed file URL'); - $this->plugin->validateUrls($data); + $this->expectExceptionMessage('Unallowed document URL'); + $plugin->validateUrls($data); } /** @@ -1425,7 +1605,7 @@ protected function createHttpClientMock(array $responses): ClientInterface { */ protected function createEntity(?string $entity_type_id = NULL, ?string $bundle = NULL): ContentEntityInterface { $entity_type_id = $entity_type_id ?? $this->plugin->getEntityType(); - $bundle = $bundle ?? $this->plugin->getEntityBundle(); + $bundle = $bundle ?? $this->plugin->getBundle(); $storage = \Drupal::entityTypeManager()->getStorage($entity_type_id); @@ -1444,18 +1624,22 @@ public function save() { return NULL; } * Plugin definition. * @param array $services * Service overrides. + * @param bool $use_plugin_class + * Whether to use the same class the `$this->plugin` or use an anymous + * class. * * @return \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface * The dummy plugin. */ - protected function createDummyPlugin(array $definition = [], array $services = []): ContentProcessorPluginInterface { + protected function createDummyPlugin(array $definition = [], array $services = [], bool $use_plugin_class = TRUE): ContentProcessorPluginInterface { $container = \drupal::getContainer(); $definition += [ 'id' => 'reliefweb_post_api.content_processor.dummy', 'label' => new TranslatableMarkup('Dummy content processor'), 'entityType' => 'dummy', - 'entityBundle' => 'dummy', + 'bundle' => 'dummy', + 'resource' => 'dummies', ]; $services = [ @@ -1469,28 +1653,88 @@ protected function createDummyPlugin(array $definition = [], array $services = [ $services['file.validator'] ?? $container->get('file.validator'), $services['file.mime_type.guesser'] ?? $container->get('file.mime_type.guesser'), $services['language_manager'] ?? $container->get('language_manager'), - $services['reliefweb_post_api.provider.manager'] ?? $container->get('reliefweb_post_api.provider.manager'), ]; - return new ($this->plugin::class)([], $definition['id'], $definition, ...$services); + if ($use_plugin_class) { + return new ($this->plugin::class)([], $definition['id'], $definition, ...$services); + } + else { + return new class([], $definition['id'], $definition, ...$services) extends ContentProcessorPluginBase { + + /** + * {@inheritdoc} + */ + public function process(array $data): ?ContentEntityInterface { + return NULL; + } + + }; + } } /** * Get some POST API test data. * - * @return array - * The data. + * @param string $bundle + * The bundle of the data. + * @param string $type + * The type of data: raw (string) or decoded (array). + * + * @return string|array + * The data as a JSON string. */ - protected function getPostApiData(): array { - $bundle = $this->plugin->getEntityBundle(); + protected function getPostApiData(string $bundle = 'report', string $type = 'decoded'): string|array { if (!isset($this->postApiData[$bundle])) { $file = __DIR__ . '/../../../data/data-' . $bundle . '.json'; - $data = json_decode(file_get_contents($file), TRUE); - $data['provider'] = 'test-provider'; - $data['bundle'] = $bundle; - $this->postApiData[$bundle] = $data; + $raw = file_get_contents($file); + $decoded = json_decode($raw, TRUE); + $decoded['provider'] = $this->getTestProvider()->uuid(); + $decoded['bundle'] = $bundle; + $this->postApiData[$bundle] = [ + 'raw' => $raw, + 'decoded' => $decoded, + ]; } - return $this->postApiData[$bundle]; + return $this->postApiData[$bundle][$type]; + } + + /** + * Get the UUID of test data. + * + * @param string $bundle + * The bundle of the data. + * + * @return string + * UUID. + */ + protected function getTestUuid(string $bundle = 'report'): string { + return $this->getPostApiData('report', 'decoded')['uuid']; + } + + /** + * Get a provider based on its name. + * + * @param string $name + * The provider name. + * + * @return \Drupal\reliefweb_post_api\Entity\ProviderInterface + * The provider. + */ + protected function getTestProvider(string $name = 'test-provider'): ProviderInterface { + return $this->providers[$name]; + } + + /** + * Get the UUID of a provider based on its name. + * + * @param string $name + * The provider name. + * + * @return string + * The provider UUID. + */ + protected function getTestProviderUuid(string $name): string { + return Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_URL), $name)->toRfc4122(); } /** diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginManagerTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginManagerTest.php index 25f6cea26..71e95b256 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginManagerTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginManagerTest.php @@ -60,6 +60,20 @@ public function testGetPlugin(): void { $this->assertNull($plugin); } + /** + * @covers ::getPluginFromProperty + */ + public function testGetPluginFromProperty(): void { + $plugin = $this->contentProcessorPluginManager->getPluginFromProperty('bundle', 'report'); + $this->assertInstanceOf(Report::class, $plugin); + + $plugin = $this->contentProcessorPluginManager->getPluginFromProperty('unknown', ''); + $this->assertNull($plugin); + + $plugin = $this->contentProcessorPluginManager->getPluginFromProperty('unknown', 'unknown'); + $this->assertNull($plugin); + } + /** * @covers ::getPluginByBundle */ @@ -74,4 +88,18 @@ public function testGetPluginByBundle(): void { $this->assertNull($plugin); } + /** + * @covers ::getPluginByResource + */ + public function testGetPluginByResource(): void { + $plugin = $this->contentProcessorPluginManager->getPluginByResource('reports'); + $this->assertInstanceOf(Report::class, $plugin); + + $plugin = $this->contentProcessorPluginManager->getPluginByResource('unknown'); + $this->assertNull($plugin); + + $plugin = $this->contentProcessorPluginManager->getPluginByResource(''); + $this->assertNull($plugin); + } + } diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldFormatter/ApiKeyFormatterTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldFormatter/ApiKeyFormatterTest.php new file mode 100644 index 000000000..ed6895bbb --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldFormatter/ApiKeyFormatterTest.php @@ -0,0 +1,47 @@ + 123]); + + /** @var Drupal\reliefweb_post_api\Plugin\Field\FieldFormatter\ApiKeyWidget $formatter */ + $formatter = \Drupal::service('plugin.manager.field.formatter')->getInstance([ + 'field_definition' => $entity->key->getFieldDefinition(), + 'view_mode' => 'full', + 'configuration' => [], + ]); + + // No value. + $result = $formatter->viewElements($entity->key, 'en'); + $this->assertSame([], $result); + + // One empty value. + $entity->key->value = ''; + $result = $formatter->viewElements($entity->key, 'en'); + $this->assertSame('API key missing', (string) $result[0]['#context']['value']); + + // One value. + $entity->key->value = 'test'; + $result = $formatter->viewElements($entity->key, 'en'); + $this->assertSame('API key exists', (string) $result[0]['#context']['value']); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldType/ApiKeyItemTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldType/ApiKeyItemTest.php new file mode 100644 index 000000000..1503c6d9d --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldType/ApiKeyItemTest.php @@ -0,0 +1,88 @@ + 123]); + + $field_storage_definition = $entity->key->getFieldDefinition()->getFieldStorageDefinition(); + + $definitions = ApiKeyItem::propertyDefinitions($field_storage_definition); + + $this->assertSame(['value', 'existing', 'pre_hashed'], array_keys($definitions)); + } + + /** + * @covers ::preSave() + */ + public function testPreSave(): void { + $manager = \Drupal::service('plugin.manager.field.field_type'); + $password = \Drupal::service('password'); + + $value = 'test'; + $hashed_value = $password->hash(trim($value)); + + $entity = Provider::create(['id' => 123]); + $entity->original = Provider::create(['id' => 123, 'key' => 'other']); + + $item = $manager->createFieldItem($entity->key, 0, NULL); + $item->setValue(['value' => $value]); + $this->assertSame($value, $item->value); + + $item->preSave(); + $this->assertNotSame($value, $item->value); + $this->assertTrue($password->check($value, $item->value)); + + $item->setValue([ + 'pre_hashed' => TRUE, + 'value' => $hashed_value, + ]); + $item->preSave(); + $this->assertSame($hashed_value, $item->value); + $this->assertSame(FALSE, $item->pre_hashed); + + $item->setValue([]); + $item->preSave(); + $this->assertSame('other', $item->value); + + $this->expectException(EntityMalformedException::class); + $item->setValue(str_pad('', PasswordInterface::PASSWORD_MAX_LENGTH + 1, '-')); + $item->preSave(); + } + + /** + * @covers ::isEmpty() + */ + public function testIsEmpty(): void { + $entity = Provider::create(['id' => 123]); + $manager = \Drupal::service('plugin.manager.field.field_type'); + + $item = $manager->createFieldItem($entity->key, 0, NULL); + $this->assertTrue($item->isEmpty()); + + $item->setValue('test'); + $this->assertFalse($item->isEmpty()); + $this->assertSame('test', $item->value); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldWidget/ApiKeyWidgetTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldWidget/ApiKeyWidgetTest.php new file mode 100644 index 000000000..be9bc55bd --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/Field/FieldWidget/ApiKeyWidgetTest.php @@ -0,0 +1,64 @@ + 'test', + '#description' => new TranslatableMarkup('test description'), + ]; + $entity = Provider::create(['id' => 123]); + + $form_object = $this->createConfiguredMock(EntityFormInterface::class, [ + 'getEntity' => $entity, + ]); + $form_state = $this->createConfiguredMock(FormStateInterface::class, [ + 'getFormObject' => $form_object, + ]); + + /** @var Drupal\reliefweb_post_api\Plugin\Field\FieldWidget\ApiKeyWidget $widget */ + $widget = \Drupal::service('plugin.manager.field.widget')->getInstance([ + 'field_definition' => $entity->key->getFieldDefinition(), + ]); + + // New entity. + $entity->enforceIsNew(TRUE); + $result = $widget->formElement($entity->key, 0, $element, $form, $form_state); + $this->assertSame([ + '#title' => 'test', + '#description' => $element['#description'], + '#type' => 'textfield', + '#default_value' => NULL, + '#size' => $widget->getSetting('size'), + '#placeholder' => $widget->getSetting('placeholder'), + '#maxlength' => $entity->key->getFieldDefinition()->getSetting('max_length'), + ], $result['value']); + + // Existing entity. + $entity->enforceIsNew(FALSE); + $result = $widget->formElement($entity->key, 0, $element, $form, $form_state); + $this->assertSame('test description. Leave blank to keep the existing API key.', (string) $result['value']['#description'] ?? ''); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php index c4c264650..4e671347a 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php @@ -30,7 +30,7 @@ protected function setUp(): void { * @covers ::getPluginLabel */ public function testGetPluginLabel(): void { - $this->assertEquals('Report content processor', (string) $this->plugin->getPluginLabel()); + $this->assertEquals('Reports', (string) $this->plugin->getPluginLabel()); } /** @@ -41,10 +41,17 @@ public function testGetEntityType(): void { } /** - * @covers ::getEntityBundle + * @covers ::getBundle */ - public function testGetEntityBundle(): void { - $this->assertEquals('report', $this->plugin->getEntityBundle()); + public function testGetBundle(): void { + $this->assertEquals('report', $this->plugin->getBundle()); + } + + /** + * @covers ::getResource + */ + public function testGetResource(): void { + $this->assertEquals('reports', $this->plugin->getResource()); } /** @@ -67,6 +74,8 @@ public function testProcess(): void { unset($data['file']); unset($data['image']); + $provider = $this->getTestProvider(); + $entity = $this->createEntity('node', 'report'); $entity->uuid = $plugin->generateUuid($data['url']); @@ -74,6 +83,7 @@ public function testProcess(): void { ->method('loadEntityByUuid') ->willReturnMap([ ['node', $entity->uuid(), $entity], + ['reliefweb_post_api_provider', $provider->uuid(), $provider], ]); $plugin->process($data); @@ -93,6 +103,8 @@ public function testProcessWrongBundle(): void { $data = ['source' => [123]] + $this->getPostApiData(); + $provider = $this->getTestProvider(); + $entity = $this->createEntity('node', 'training'); $entity->nid = 123; $entity->uuid = $plugin->generateUuid($data['url']); @@ -102,6 +114,7 @@ public function testProcessWrongBundle(): void { ->method('loadEntityByUuid') ->willReturnMap([ ['node', $entity->uuid(), $entity], + ['reliefweb_post_api_provider', $provider->uuid(), $provider], ]); $this->expectException(ContentProcessorException::class); @@ -110,4 +123,30 @@ public function testProcessWrongBundle(): void { $plugin->process($data); } + /** + * @covers ::validateUrls + */ + public function testValidateUrlsUnallowedImageUrl(): void { + $data = $this->getPostApiData(); + $data['image']['url'] = 'https://wrong.test/test.jpg'; + + // Unallowed image URL. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Unallowed image URL'); + $this->plugin->validateUrls($data); + } + + /** + * @covers ::validateUrls + */ + public function testValidateUrlsUnallowedFileUrl(): void { + $data = $this->getPostApiData(); + $data['file'][0]['url'] = 'https://wrong.test/test.pdf'; + + // Unallowed file URL. + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('Unallowed file URL'); + $this->plugin->validateUrls($data); + } + } diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/ProviderListBuilderTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/ProviderListBuilderTest.php new file mode 100644 index 000000000..88961d7be --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/ProviderListBuilderTest.php @@ -0,0 +1,52 @@ +getListBuilder('reliefweb_post_api_provider'); + $this->assertArrayHasKey('uuid', $list_builder->buildHeader()); + } + + /** + * @covers ::buildRow + */ + public function testBuildRow(): void { + $source = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->create([ + 'tid' => 123, + 'vid' => 'source', + 'name' => 'test source', + 'field_shortname' => 'ts', + ]); + + $provider = \Drupal::entityTypeManager()->getStorage('reliefweb_post_api_provider')->create([ + 'id' => 456, + 'name' => 'test', + 'uuid' => Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_URL), 'test')->toRfc4122(), + 'field_source' => [$source], + 'status' => 1, + ]); + + $list_builder = \Drupal::entityTypeManager()->getListBuilder('reliefweb_post_api_provider'); + $row = $list_builder->buildRow($provider); + $this->assertArrayHasKey('uuid', $row); + $this->assertNotEmpty($row['source']['data']['#items']); + } + +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Services/ProviderManagerTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Services/ProviderManagerTest.php deleted file mode 100644 index 010cd5676..000000000 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Services/ProviderManagerTest.php +++ /dev/null @@ -1,43 +0,0 @@ - 'test-provider-key', - 'url_pattern' => '@^https://test.test/@', - ]; - new Settings($settings); - - $manager = \Drupal::service('reliefweb_post_api.provider.manager'); - - $provider = $manager->getProvider(''); - $this->assertNull($provider); - - $provider = $manager->getProvider('test-unknow'); - $this->assertNull($provider); - - $provider = $manager->getProvider('test-provider'); - $this->assertInstanceOf(ProviderInterface::class, $provider); - } - -} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Theme/ThemeNegotiatorTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Theme/ThemeNegotiatorTest.php new file mode 100644 index 000000000..332f4e6ea --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Theme/ThemeNegotiatorTest.php @@ -0,0 +1,44 @@ +createConfiguredMock(RouteMatchInterface::class, [ + 'getRouteName' => 'entity.reliefweb_post_api_provider.add_form', + ]); + + $theme_negotiator = new ThemeNegotiator(); + + $this->assertTrue($theme_negotiator->applies($route_match)); + } + + /** + * @covers ::determineActiveTheme + */ + public function testDetermineActiveTheme(): void { + $route_match = $this->createMock(RouteMatchInterface::class); + + $theme_negotiator = new ThemeNegotiator(); + + $this->assertSame('common_design_subtheme', $theme_negotiator->determineActiveTheme($route_match)); + } + +} From 502e815421d96c9fd85ddce8ff77a1e1823b9cfb Mon Sep 17 00:00:00 2001 From: orakili Date: Fri, 23 Feb 2024 04:09:29 +0000 Subject: [PATCH 10/73] fix: rare spacing issue --- .../components/rw-document/rw-document.css | 1 + 1 file changed, 1 insertion(+) diff --git a/html/themes/custom/common_design_subtheme/components/rw-document/rw-document.css b/html/themes/custom/common_design_subtheme/components/rw-document/rw-document.css index e6ff27565..4c7096820 100644 --- a/html/themes/custom/common_design_subtheme/components/rw-document/rw-document.css +++ b/html/themes/custom/common_design_subtheme/components/rw-document/rw-document.css @@ -68,6 +68,7 @@ } .rw-document > footer dl.rw-entity-meta dd li:last-child:after, .rw-document > footer dl.rw-entity-meta dd li.rw-entity-meta__tag-value__list__item--last:after { + margin: 0; content: ""; } .rw-entity-details dl.rw-entity-meta.rw-article-meta dd::after { From 1de47472df35f7141b260d668453aea7c3074244 Mon Sep 17 00:00:00 2001 From: orakili Date: Sun, 25 Feb 2024 23:22:46 +0000 Subject: [PATCH 11/73] chore: fixes, improvements and schema route Refs: RW-831 --- .../reliefweb_post_api.routing.yml | 14 ++++++- .../src/Commands/ReliefWebPostApiCommands.php | 33 +++++++++------- .../src/Controller/ReliefWebPostApi.php | 35 ++++++++++++++++- .../tests/data/empty-schema-test.json | 0 .../tests/data/schemas/empty.json | 0 .../tests/data/test-schema-02.json | 23 ----------- .../Commands/ReliefWebPostApiCommandsTest.php | 19 ++++++--- .../Controller/ReliefWebPostApiTest.php | 39 +++++++++++++++++++ 8 files changed, 116 insertions(+), 47 deletions(-) create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/empty-schema-test.json create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/schemas/empty.json delete mode 100644 html/modules/custom/reliefweb_post_api/tests/data/test-schema-02.json diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml index f8af70cf5..3aa5385a0 100644 --- a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.routing.yml @@ -13,5 +13,15 @@ reliefweb_post_api.post: - DELETE requirements: _access: 'TRUE' - resource: "[a-z]+" - uuid: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + resource: '^[a-z][a-z_-]+[a-z]$' + uuid: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + +reliefweb_post_api.schema: + path: '/post-api-schemas/v2/{schema}' + defaults: + _controller: '\Drupal\reliefweb_post_api\Controller\ReliefWebPostApi::getJsonSchema' + methods: + - GET + requirements: + _access: 'TRUE' + schema: '^[a-z][a-z_-]+[a-z]\.json$' diff --git a/html/modules/custom/reliefweb_post_api/src/Commands/ReliefWebPostApiCommands.php b/html/modules/custom/reliefweb_post_api/src/Commands/ReliefWebPostApiCommands.php index e5c6136b8..da87a3278 100644 --- a/html/modules/custom/reliefweb_post_api/src/Commands/ReliefWebPostApiCommands.php +++ b/html/modules/custom/reliefweb_post_api/src/Commands/ReliefWebPostApiCommands.php @@ -55,39 +55,42 @@ public function process(array $options = [ } $data = $item->data; - $plugin = $this->contentProcessorPluginManager->getPluginByBundle($data['bundle'] ?? ''); + $item_id = $item->item_id; + $bundle = $data['bundle'] ?? 'unknown'; + $uuid = $data['uuid'] ?? 'missing UUID'; + $plugin = $this->contentProcessorPluginManager->getPluginByBundle($bundle); - // @todo Add the item UUID and/or URL to the logs to help identifying - // the problematic document in the logs. if (isset($plugin)) { - $this->logger->info(strtr('Processing queued @bundle: @item_id.', [ - '@bundle' => $data['bundle'], - '@item_id' => $item->item_id, + $this->logger->info(strtr('Processing queued @bundle @item_id (@uuid).', [ + '@bundle' => $bundle, + '@uuid' => $uuid, + '@item_id' => $item_id, ])); - // @todo log some info about the created entity. - // @todo maybe return the created entity so we can do something with it. + // Attempt to create/update a resource based on the provided data. try { $entity = $plugin->process($data); - $this->logger->info(strtr('Successfully @action @bundle entity with id @id.', [ + $this->logger->info(strtr('Successfully @action @bundle entity with ID: @id (@uuid).', [ '@action' => mb_stripos($entity->getRevisionLogMessage(), 'automatic creation') !== FALSE ? 'created' : 'updated', - '@bundle' => $data['bundle'], + '@bundle' => $entity->bundle(), '@id' => $entity->id(), + '@uuid' => $entity->uuid(), ])); } catch (\Exception $exception) { - $this->logger->error(strtr('Error processing @bundle @item_id: @error.', [ - '@bundle' => $data['bundle'], - '@item_id' => $item->item_id, + $this->logger->error(strtr('Error processing @bundle @item_id (@uuid): @error.', [ + '@bundle' => $bundle, + '@item_id' => $item_id, + '@uuid' => $uuid, '@error' => $exception->getMessage(), ])); } } else { $this->logger->error(strtr('Unsupported bundle: @bundle, skipping item: @item_id.', [ - '@bundle' => $data['bundle'] ?? 'unknown', - '@item_id' => $item->item_id, + '@bundle' => $bundle, + '@item_id' => $item_id, ])); } diff --git a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php index 8bafc5ecd..12bbe5c64 100644 --- a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php +++ b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php @@ -5,6 +5,7 @@ namespace Drupal\reliefweb_post_api\Controller; use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Extension\ExtensionPathResolver; use Drupal\Core\Queue\QueueFactory; use Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -29,12 +30,15 @@ class ReliefWebPostApi extends ControllerBase { * The request stack. * @param \Drupal\Core\Queue\QueueFactory $queueFactory * The queue factory. + * @param \Drupal\Core\Extension\ExtensionPathResolver $pathResolver + * The path resolver service. * @param \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface $contentProcessorPluginManager * The ReliefWeb POST API content processor plugin manager. */ public function __construct( protected RequestStack $requestStack, protected QueueFactory $queueFactory, + protected ExtensionPathResolver $pathResolver, protected ContentProcessorPluginManagerInterface $contentProcessorPluginManager ) {} @@ -45,12 +49,13 @@ public static function create(ContainerInterface $container) { return new static( $container->get('request_stack'), $container->get('queue'), + $container->get('extension.path.resolver'), $container->get('plugin.manager.reliefweb_post_api.content_processor') ); } /** - * POST endpoint. + * Post content endpoint. * * @param string $resource * Content resource (ex: reports). @@ -163,4 +168,32 @@ public function postContent(string $resource, string $uuid): JsonResponse { return $response; } + /** + * Get a JSON schema. + * + * @param string $schema + * The name of the schema file (ex: report.json). + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * The response: 200, 4xx or 5xx + */ + public function getJsonSchema(string $schema): JsonResponse { + if (preg_match('/^[a-z][a-z_-]+[a-z]\.json$/', $schema) !== 1) { + return new JsonResponse('Invalid schema file name.', 400); + } + + $path = $this->pathResolver->getPath('module', 'reliefweb_post_api'); + $file = $path . '/schemas/' . $schema; + if (!file_exists($file)) { + return new JsonResponse('Unknown schema file.', 404); + } + + $content = @file_get_contents($file); + if (empty($content)) { + return new JsonResponse('Internal server error.', 500); + } + + return new JsonResponse($content, 200); + } + } diff --git a/html/modules/custom/reliefweb_post_api/tests/data/empty-schema-test.json b/html/modules/custom/reliefweb_post_api/tests/data/empty-schema-test.json new file mode 100644 index 000000000..e69de29bb diff --git a/html/modules/custom/reliefweb_post_api/tests/data/schemas/empty.json b/html/modules/custom/reliefweb_post_api/tests/data/schemas/empty.json new file mode 100644 index 000000000..e69de29bb diff --git a/html/modules/custom/reliefweb_post_api/tests/data/test-schema-02.json b/html/modules/custom/reliefweb_post_api/tests/data/test-schema-02.json deleted file mode 100644 index ec481880a..000000000 --- a/html/modules/custom/reliefweb_post_api/tests/data/test-schema-02.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "url": "https://test.test/test/2", - "title": "This a test document - DOC2 - attachment", - "source": [1503], - "country": [13, 14], - "format": [10], - "language": [267, 268], - "published": "2024-06-06T01:00:00+00:00", - "body": "This is the **body** text in markdown format.\n\nIt has some paragraphs \nand a line break.

TITLE

pouet parag

  • dsdf
  • sdfgsdgf

sdfgs Bidule

", - "embargoed": "2024-06-06T07:00:00+00:00", - "file": [ - { - "url": "https://reliefweb.int/attachments/a2e0b418-1aef-4e63-9cd2-6277a29d6ccf/test-01.pdf", - "checksum": "0fc1dd7bae056baf7fff962117363e1f", - "description": "First attachment", - "language": "en" - } - ], - "disaster": [51754], - "disaster_type": [4628], - "theme": [4587, 4595, 4603], - "origin": "https://test.test/test/2" -} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Commands/ReliefWebPostApiCommandsTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Commands/ReliefWebPostApiCommandsTest.php index 1e52bd8d0..26041f82f 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Commands/ReliefWebPostApiCommandsTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Commands/ReliefWebPostApiCommandsTest.php @@ -99,7 +99,10 @@ public function testProcessUnsupportedBundle(): void { */ public function testProcessProcessException(): void { $item = new \stdClass(); - $item->data = ['bundle' => 'report']; + $item->data = [ + 'bundle' => 'report', + 'uuid' => 'ba98249e-f453-4bff-92a7-5ffa7229d62b', + ]; $item->item_id = 'abc'; $queue = $this->createConfiguredMock(QueueInterface::class, [ @@ -126,8 +129,8 @@ public function testProcessProcessException(): void { $handler->process(['limit' => 1]); $this->assertSame([ - ['info', 'Processing queued report: abc.', []], - ['error', 'Error processing report abc: test exception.', []], + ['info', 'Processing queued report abc (ba98249e-f453-4bff-92a7-5ffa7229d62b).', []], + ['error', 'Error processing report abc (ba98249e-f453-4bff-92a7-5ffa7229d62b): test exception.', []], ['info', 'Processed 1 queued submissions.', []], ], $this->logger->cleanLogs()); } @@ -137,7 +140,10 @@ public function testProcessProcessException(): void { */ public function testProcess(): void { $item = new \stdClass(); - $item->data = ['bundle' => 'report']; + $item->data = [ + 'bundle' => 'report', + 'uuid' => 'ba98249e-f453-4bff-92a7-5ffa7229d62b', + ]; $item->item_id = 'abc'; $queue = $this->createConfiguredMock(QueueInterface::class, [ @@ -150,6 +156,7 @@ public function testProcess(): void { $entity = $this->createConfiguredMock(Node::class, [ 'id' => 123, + 'uuid' => 'ba98249e-f453-4bff-92a7-5ffa7229d62b', 'getRevisionLogMessage' => 'Automatic creation from POST API.', ]); @@ -168,8 +175,8 @@ public function testProcess(): void { $handler->process(['limit' => 1]); $this->assertSame([ - ['info', 'Processing queued report: abc.', []], - ['info', 'Successfully created report entity with id 123.', []], + ['info', 'Processing queued report abc (ba98249e-f453-4bff-92a7-5ffa7229d62b).', []], + ['info', 'Successfully created entity with ID: 123 (ba98249e-f453-4bff-92a7-5ffa7229d62b).', []], ['info', 'Processed 1 queued submissions.', []], ], $this->logger->cleanLogs()); } diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php index 43fae9442..e826f7fe5 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php @@ -4,6 +4,7 @@ namespace Drupal\Tests\reliefweb_post_api\ExistingSite\Controller; +use Drupal\Core\Extension\ExtensionPathResolver; use Drupal\Core\Queue\QueueFactory; use Drupal\Core\Queue\QueueInterface; use Drupal\reliefweb_post_api\Controller\ReliefWebPostApi; @@ -322,6 +323,43 @@ public function testPostContent(): void { $this->assertStringContainsString('Document queued for processing.', $response->getContent()); } + /** + * @covers ::getJsonSchema + */ + public function testGetJsonSchema(): void { + $controller = $this->createTestController(); + + $response = $controller->getJsonSchema('@fgh%'); + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('Invalid schema file name.', $response->getContent()); + + $response = $controller->getJsonSchema('test.json'); + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringContainsString('Unknown schema file.', $response->getContent()); + + $response = $controller->getJsonSchema('report.json'); + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('uuid', $response->getContent()); + $this->assertTrue(json_validate($response->getContent())); + } + + /** + * @covers ::getJsonSchema + */ + public function testGetJsonSchemaEmpty(): void { + $path_resolver = $this->createConfiguredMock(ExtensionPathResolver::class, [ + 'getPath' => __DIR__ . '/../../../data/', + ]); + + $controller = $this->createTestController([ + 'extension.path.resolver' => $path_resolver, + ]); + + $response = $controller->getJsonSchema('empty.json'); + $this->assertSame(500, $response->getStatusCode()); + $this->assertStringContainsString('Internal server error.', $response->getContent()); + } + /** * Create a mock request. * @@ -391,6 +429,7 @@ protected function createTestController(array $services = []): ReliefWebPostApi $services = [ $services['request_stack'] ?? $container->get('request_stack'), $services['queue'] ?? $container->get('queue'), + $services['extension.path.resolver'] ?? $container->get('extension.path.resolver'), $services['plugin.manager.reliefweb_post_api.content_processor'] ?? $container->get('plugin.manager.reliefweb_post_api.content_processor'), ]; From 0cd9f11f2cdfe6d9599274ff37df42d6e268b8f2 Mon Sep 17 00:00:00 2001 From: orakili Date: Mon, 26 Feb 2024 00:38:05 +0000 Subject: [PATCH 12/73] fix: schema route Refs: RW-831 --- .../custom/reliefweb_post_api/schemas/{ => v2}/report.json | 2 +- .../reliefweb_post_api/src/Controller/ReliefWebPostApi.php | 4 ++-- .../src/Plugin/ContentProcessorPluginBase.php | 2 +- .../reliefweb_post_api/tests/data/schemas/{ => v2}/empty.json | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename html/modules/custom/reliefweb_post_api/schemas/{ => v2}/report.json (99%) rename html/modules/custom/reliefweb_post_api/tests/data/schemas/{ => v2}/empty.json (100%) diff --git a/html/modules/custom/reliefweb_post_api/schemas/report.json b/html/modules/custom/reliefweb_post_api/schemas/v2/report.json similarity index 99% rename from html/modules/custom/reliefweb_post_api/schemas/report.json rename to html/modules/custom/reliefweb_post_api/schemas/v2/report.json index bd8d7dc59..2a9e64d0b 100644 --- a/html/modules/custom/reliefweb_post_api/schemas/report.json +++ b/html/modules/custom/reliefweb_post_api/schemas/v2/report.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://reliefweb.int/post-api-schemas/report.json", + "$id": "https://reliefweb.int/post-api-schemas/v2/report.json", "title": "ReliefWeb POST API schema - report resource", "type": "object", "properties": { diff --git a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php index 12bbe5c64..7712c1068 100644 --- a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php +++ b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php @@ -183,7 +183,7 @@ public function getJsonSchema(string $schema): JsonResponse { } $path = $this->pathResolver->getPath('module', 'reliefweb_post_api'); - $file = $path . '/schemas/' . $schema; + $file = $path . '/schemas/v2/' . $schema; if (!file_exists($file)) { return new JsonResponse('Unknown schema file.', 404); } @@ -193,7 +193,7 @@ public function getJsonSchema(string $schema): JsonResponse { return new JsonResponse('Internal server error.', 500); } - return new JsonResponse($content, 200); + return new JsonResponse($content, 200, json: TRUE); } } diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php index d5b84a19c..d6948f6f0 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php @@ -199,7 +199,7 @@ public function getJsonSchema(): string { if (!isset($this->jsonSchema)) { $bundle = $this->getbundle(); $path = $this->pathResolver->getPath('module', 'reliefweb_post_api'); - $schema = @file_get_contents($path . '/schemas/' . $bundle . '.json'); + $schema = @file_get_contents($path . '/schemas/v2/' . $bundle . '.json'); if ($schema === FALSE) { throw new ContentProcessorException(strtr('Missing @bundle JSON schema.', [ '@bundle' => $bundle, diff --git a/html/modules/custom/reliefweb_post_api/tests/data/schemas/empty.json b/html/modules/custom/reliefweb_post_api/tests/data/schemas/v2/empty.json similarity index 100% rename from html/modules/custom/reliefweb_post_api/tests/data/schemas/empty.json rename to html/modules/custom/reliefweb_post_api/tests/data/schemas/v2/empty.json From 0ce0d0e255010bff2e2334b63858623c78f5ae60 Mon Sep 17 00:00:00 2001 From: orakili Date: Tue, 27 Feb 2024 06:12:57 +0000 Subject: [PATCH 13/73] chore: update default document UUID namespace to use one derived from the reliefweb.int domain Refs: RW-831 --- html/modules/custom/reliefweb_post_api/schemas/v2/report.json | 2 +- .../src/Plugin/ContentProcessorPluginBase.php | 4 +++- .../custom/reliefweb_post_api/tests/data/data-report.json | 2 +- .../ExistingSite/Plugin/ContentProcessorPluginBaseTest.php | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/html/modules/custom/reliefweb_post_api/schemas/v2/report.json b/html/modules/custom/reliefweb_post_api/schemas/v2/report.json index 2a9e64d0b..9462dde50 100644 --- a/html/modules/custom/reliefweb_post_api/schemas/v2/report.json +++ b/html/modules/custom/reliefweb_post_api/schemas/v2/report.json @@ -11,7 +11,7 @@ "maxLength": 2048 }, "uuid": { - "description": "The Universally unique identifier (UUID) version 5 generated from the URL property with the predefined ns:URL namespace.", + "description": "The universally unique identifier (UUID) version 5 generated from the URL property above, with the namespace: '8e27a998-c362-5d1f-b152-d474e1d36af2'.", "type": "string", "format": "uuid" }, diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php index d6948f6f0..087b5db97 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php @@ -868,7 +868,9 @@ public function validateFile(File $file, array $validators = []): array { * {@inheritdoc} */ public function generateUuid(string $string, ?string $namespace = NULL): string { - $namespace = $namespace ?? Uuid::NAMESPACE_URL; + /* The default namespace is the UUID generated with + * Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_DNS), 'reliefweb.int')->toRfc4122(); */ + $namespace = $namespace ?? '8e27a998-c362-5d1f-b152-d474e1d36af2'; return Uuid::v5(Uuid::fromString($namespace), $string)->toRfc4122(); } diff --git a/html/modules/custom/reliefweb_post_api/tests/data/data-report.json b/html/modules/custom/reliefweb_post_api/tests/data/data-report.json index 713b6ebbe..790d55a5b 100644 --- a/html/modules/custom/reliefweb_post_api/tests/data/data-report.json +++ b/html/modules/custom/reliefweb_post_api/tests/data/data-report.json @@ -1,6 +1,6 @@ { "url": "https://test.test/test/report", - "uuid": "d791d10c-1bbb-5418-a719-b58ed3b3f043", + "uuid": "22ce51c2-d7ed-59b2-b71a-23fb56685df9", "title": "This a test document - report", "source": [1503], "country": [13, 14], diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php index 4e0f04722..d704d3140 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php @@ -1462,7 +1462,7 @@ public function testValidateFile(): void { * @covers ::generateUuid */ public function testGenerateUuid(): void { - $uuid = 'bda0e2da-4229-53aa-9206-db72dfdac519'; + $uuid = 'c1cd5878-f50e-5b94-b8ed-029bd92ab1af'; $this->assertSame($uuid, $this->plugin->generateUuid('https://test.test')); } From 1cd8e46788bb574410ff442fdf4359ef9ac44e2a Mon Sep 17 00:00:00 2001 From: orakili Date: Wed, 28 Feb 2024 05:02:00 +0000 Subject: [PATCH 14/73] feat: add field to allow to set the default status of submitted content per provider Refs: RW-831 --- ...er.reliefweb_post_api_provider.default.yml | 29 +++-- ...er.reliefweb_post_api_provider.default.yml | 22 ++-- ...ost_api_provider.field_resource_status.yml | 23 ++++ ...ost_api_provider.field_resource_status.yml | 48 +++++++++ .../src/Entity/Provider.php | 15 +++ .../src/Entity/ProviderInterface.php | 8 ++ .../src/Form/ProviderForm.php | 30 ++++++ .../ContentProcessor/Report.php | 2 +- .../src/ProviderListBuilder.php | 5 + .../src/ExistingSite/Entity/ProviderTest.php | 26 +++++ .../ExistingSite/Form/ProviderFormTest.php | 102 ++++++++++++++++++ 11 files changed, 292 insertions(+), 18 deletions(-) create mode 100644 config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_resource_status.yml create mode 100644 config/field.storage.reliefweb_post_api_provider.field_resource_status.yml diff --git a/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml b/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml index bd0dcea5e..b9ca199e5 100644 --- a/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml +++ b/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml @@ -7,6 +7,7 @@ dependencies: - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_resource_status - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_source - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_user - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_webhook_url @@ -21,7 +22,7 @@ mode: default content: field_document_url: type: link_default - weight: 5 + weight: 6 region: content settings: placeholder_url: '' @@ -29,7 +30,7 @@ content: third_party_settings: { } field_file_url: type: link_default - weight: 7 + weight: 8 region: content settings: placeholder_url: '' @@ -37,7 +38,7 @@ content: third_party_settings: { } field_image_url: type: link_default - weight: 6 + weight: 7 region: content settings: placeholder_url: '' @@ -45,15 +46,21 @@ content: third_party_settings: { } field_notify: type: email_default - weight: 8 + weight: 9 region: content settings: placeholder: '' size: 60 third_party_settings: { } + field_resource_status: + type: options_select + weight: 2 + region: content + settings: { } + third_party_settings: { } field_source: type: reliefweb_entity_reference_select - weight: 2 + weight: 3 region: content settings: sort: label @@ -97,7 +104,7 @@ content: third_party_settings: { } field_user: type: entity_reference_autocomplete - weight: 3 + weight: 4 region: content settings: match_operator: CONTAINS @@ -107,7 +114,7 @@ content: third_party_settings: { } field_webhook_url: type: link_default - weight: 9 + weight: 10 region: content settings: placeholder_url: '' @@ -115,9 +122,11 @@ content: third_party_settings: { } key: type: reliefweb_post_api_key - weight: 4 + weight: 5 region: content - settings: { } + settings: + size: 60 + placeholder: '' third_party_settings: { } name: type: string_textfield @@ -135,7 +144,7 @@ content: third_party_settings: { } status: type: boolean_checkbox - weight: 10 + weight: 11 region: content settings: display_label: true diff --git a/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml b/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml index 84b7dfd2e..37c7f08c2 100644 --- a/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml +++ b/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml @@ -7,6 +7,7 @@ dependencies: - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_resource_status - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_source - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_user - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_webhook_url @@ -29,7 +30,7 @@ content: rel: '' target: '' third_party_settings: { } - weight: 4 + weight: 5 region: content field_file_url: type: link @@ -41,7 +42,7 @@ content: rel: '' target: '' third_party_settings: { } - weight: 6 + weight: 7 region: content field_image_url: type: link @@ -53,14 +54,21 @@ content: rel: '' target: '' third_party_settings: { } - weight: 5 + weight: 6 region: content field_notify: type: basic_string label: above settings: { } third_party_settings: { } - weight: 7 + weight: 8 + region: content + field_resource_status: + type: list_default + label: above + settings: { } + third_party_settings: { } + weight: 2 region: content field_source: type: entity_reference_label @@ -68,7 +76,7 @@ content: settings: link: true third_party_settings: { } - weight: 2 + weight: 3 region: content field_user: type: entity_reference_label @@ -76,7 +84,7 @@ content: settings: link: true third_party_settings: { } - weight: 3 + weight: 4 region: content field_webhook_url: type: link @@ -88,7 +96,7 @@ content: rel: '' target: '' third_party_settings: { } - weight: 8 + weight: 9 region: content name: type: string diff --git a/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_resource_status.yml b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_resource_status.yml new file mode 100644 index 000000000..36ed62063 --- /dev/null +++ b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_resource_status.yml @@ -0,0 +1,23 @@ +uuid: 08651c89-5d06-4e9a-8554-20cb40cc64f6 +langcode: en +status: true +dependencies: + config: + - field.storage.reliefweb_post_api_provider.field_resource_status + module: + - options + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.field_resource_status +field_name: field_resource_status +entity_type: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +label: 'Submission status' +description: 'Default status of the created content after processing.' +required: true +translatable: false +default_value: + - + value: pending +default_value_callback: '' +settings: { } +field_type: list_string diff --git a/config/field.storage.reliefweb_post_api_provider.field_resource_status.yml b/config/field.storage.reliefweb_post_api_provider.field_resource_status.yml new file mode 100644 index 000000000..9a9f72569 --- /dev/null +++ b/config/field.storage.reliefweb_post_api_provider.field_resource_status.yml @@ -0,0 +1,48 @@ +uuid: 0e49a2d5-e72b-455f-8b68-a7eb4b78eb83 +langcode: en +status: true +dependencies: + module: + - options + - reliefweb_post_api +id: reliefweb_post_api_provider.field_resource_status +field_name: field_resource_status +entity_type: reliefweb_post_api_provider +type: list_string +settings: + allowed_values: + - + value: draft + label: Draft + - + value: pending + label: Pending + - + value: on-hold + label: On-hold + - + value: to-review + label: 'To review' + - + value: published + label: Published + - + value: archive + label: Archive + - + value: reference + label: Reference + - + value: embargoed + label: Embargoed + - + value: refused + label: Refused + allowed_values_function: '' +module: options +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php b/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php index 25499ce10..898414c05 100644 --- a/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php +++ b/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php @@ -203,6 +203,21 @@ public function getUserId(): int { return (int) $this->get($field)->target_id; } + /** + * {@inheritdoc} + */ + public function getDefaultResourceStatus(): string { + $field = 'field_resource_status'; + // Draft is the only common status among all RW content entities. + if (!$this->hasField($field)) { + return 'draft'; + } + if (empty($this->get($field)->value)) { + return 'draft'; + } + return $this->get($field)->value; + } + /** * {@inheritdoc} */ diff --git a/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php b/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php index fcabb507b..f002b5f1c 100644 --- a/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php +++ b/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php @@ -46,6 +46,14 @@ public function getEmailsToNotify(): array; */ public function getUserId(): int; + /** + * Get the default moderation status for the created/updated entities. + * + * @return string + * Moeration status. Defaults to 'draft'. + */ + public function getDefaultResourceStatus(): string; + /** * Check if a provider with the given ID exits and its API key is valid. * diff --git a/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php b/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php index d91d533d1..62710c890 100644 --- a/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php +++ b/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php @@ -10,6 +10,7 @@ use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Password\PasswordInterface; +use Drupal\reliefweb_moderation\ModerationServiceBase; use Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -68,6 +69,35 @@ public function form(array $form, FormStateInterface $form_state) { return $form; } + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + parent::validateForm($form, $form_state); + + // No need to do further validation if there is already an error on the + // resource or resource status. For example if one is missing or not a valid + // value. + $errors = $form_state->getErrors(); + if (!isset($errors['resource']) && !isset($errors['field_resource_status'])) { + $resource = $form_state->getValue(['resource', 0, 'value']); + $status = $form_state->getValue(['field_resource_status', 0, 'value']); + + $plugin = $this->contentProcessorPluginManager->getPluginByResource($resource); + $bundle = $plugin->getBundle(); + $service = ModerationServiceBase::getModerationService($bundle); + $statuses = $service->getStatuses(); + + if (!isset($statuses[$status])) { + $error = $this->t('@status is not supported for this resource, please select one of @statuses', [ + '@status' => $form['field_resource_status']['widget']['#options'][$status] ?? $status, + '@statuses' => implode(', ', $statuses), + ]); + $form_state->setError($form['field_resource_status']['widget'], $error); + } + } + } + /** * {@inheritdoc} */ diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php index 82776ffe7..280076ba4 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php @@ -110,7 +110,7 @@ public function process(array $data): ?ContentEntityInterface { $node->field_ocha_product->setValue(NULL); // Set the new status. - $node->moderation_status = 'pending'; + $node->moderation_status = $provider->getDefaultResourceStatus(); // Set the log message based on whether it was updated or created. $message = $node->isNew() ? 'Automatic creation from POST API.' : 'Automatic update from POST API.'; diff --git a/html/modules/custom/reliefweb_post_api/src/ProviderListBuilder.php b/html/modules/custom/reliefweb_post_api/src/ProviderListBuilder.php index d62e99e7a..547d99e15 100644 --- a/html/modules/custom/reliefweb_post_api/src/ProviderListBuilder.php +++ b/html/modules/custom/reliefweb_post_api/src/ProviderListBuilder.php @@ -19,6 +19,7 @@ public function buildHeader(): array { $header['name'] = $this->t('Name'); $header['uuid'] = $this->t('UUID'); $header['resource'] = $this->t('Resource'); + $header['resource_status'] = $this->t('Default status'); $header['source'] = $this->t('Sources'); $header['status'] = $this->t('Active'); return $header + parent::buildHeader(); @@ -41,6 +42,10 @@ public function buildRow(EntityInterface $entity): array { 'label' => 'hidden', ]); + $row['resource_status']['data'] = $entity->field_resource_status->view([ + 'label' => 'hidden', + ]); + $sources = []; foreach ($entity->field_source as $item) { $source = $item->entity; diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php index 03c6055c3..69e28f730 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php @@ -25,12 +25,14 @@ class ProviderTest extends ExistingSiteBase { protected $data = [ 'name' => 'test-provider', 'key' => 'test-provider-key', + 'resource' => 'reports', 'field_document_url' => ['https://test.test/', 'https://test1.test/'], 'field_file_url' => ['https://test.test/'], 'field_image_url' => ['https://test.test/'], 'field_notify' => ['test@test.test'], 'field_source' => [1503], 'field_user' => 12, + 'field_resource_status' => 'pending', ]; /** @@ -145,6 +147,30 @@ public function testGetUserId(): void { $this->assertEquals(2, $provider->getUserId()); } + /** + * @covers ::getDefaultResourceStatus + */ + public function testGetDefaultResourceStatus(): void { + $data = $this->data; + + $provider = $this->createProvider($data); + $this->assertEquals($this->data['field_resource_status'], $provider->getDefaultResourceStatus()); + + $data['field_resource_status'] = []; + $provider = $this->createProvider($data); + $this->assertEquals('draft', $provider->getDefaultResourceStatus()); + + // The default value is "pending" when the field is initialized. + unset($data['field_resource_status']); + $provider = $this->createProvider($data); + $this->assertEquals('pending', $provider->getDefaultResourceStatus()); + + // Missing field. + $provider = $this->createNoFieldProvider(); + $this->assertFalse($provider->hasField('field_resource_status')); + $this->assertEquals('draft', $provider->getDefaultResourceStatus()); + } + /** * @covers ::validateKey */ diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Form/ProviderFormTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Form/ProviderFormTest.php index 7579b36b8..5001def95 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Form/ProviderFormTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Form/ProviderFormTest.php @@ -61,6 +61,108 @@ public function testForm(): void { $this->assertArrayHasKey('data-with-autocomplete', $form['field_source']['#attributes']); } + /** + * @covers ::validateForm() + */ + public function testValidateForm(): void { + $provider = $this->createDummyProvider(); + + $form_object = \Drupal::entityTypeManager() + ->getFormObject('reliefweb_post_api_provider', 'default') + ->setEntity($provider); + + $form_state = new FormState(); + + $form = \Drupal::service('form_builder') + ->buildForm($form_object, $form_state); + + // Missing resource. + $form_state->clearErrors(); + $form_state->unsetValue(['resource', 0, 'value']); + $form_object->validateForm($form, $form_state); + $errors = $form_state->getErrors(); + $this->assertArrayHasKey('resource', $errors); + $this->assertEquals('This value should not be null.', (string) $errors['resource']); + + // Invalid resource. + $form_state->clearErrors(); + $form_state->setValue(['resource', 0, 'value'], 'test'); + $form_object->validateForm($form, $form_state); + $errors = $form_state->getErrors(); + $this->assertArrayHasKey('resource', $errors); + $this->assertEquals('The value you selected is not a valid choice.', (string) $errors['resource']); + + // Missing resource status. + $form_state->clearErrors(); + $form_state->setValue(['resource', 0, 'value'], 'reports'); + $form_object->validateForm($form, $form_state); + $errors = $form_state->getErrors(); + $this->assertArrayHasKey('field_resource_status', $errors); + $this->assertEquals('This value should not be null.', (string) $errors['field_resource_status']); + + // Invalid resource status. + $form_state->clearErrors(); + $form_state->setValue(['field_resource_status', 0, 'value'], 'test'); + $form_object->validateForm($form, $form_state); + $errors = $form_state->getErrors(); + $this->assertArrayHasKey('field_resource_status', $errors); + $this->assertEquals('The value you selected is not a valid choice.', (string) $errors['field_resource_status']); + + // Valid resouce and resource status. + $form_state->clearErrors(); + $form_state->setValue(['resource', 0, 'value'], 'reports'); + $form_state->setValue(['field_resource_status', 0, 'value'], 'pending'); + $form_object->validateForm($form, $form_state); + $errors = $form_state->getErrors(); + $this->assertArrayNotHasKey('resource', $errors); + $this->assertArrayNotHasKey('field_resource_status', $errors); + } + + /** + * @covers ::validateForm() + */ + public function testValidateFormUnallowedStatus(): void { + $provider = $this->createDummyProvider(); + + $form_object = \Drupal::entityTypeManager() + ->getFormObject('reliefweb_post_api_provider', 'default') + ->setEntity($provider); + + // Skip errors due to unallowed values for the resource and resource status + // fields so we can test unallowed statuses. + $form_state = new class() extends FormState { + + /** + * {@inheritdoc} + */ + public function getErrors() { + $errors = $this->errors; + foreach ($errors as $key => $error) { + if ($key === 'resource' || $key === 'field_resource_status') { + if ((string) $error === 'The value you selected is not a valid choice.') { + unset($errors[$key]); + } + } + } + return $errors; + } + + }; + + $form = \Drupal::service('form_builder') + ->buildForm($form_object, $form_state); + + // Test invalid choice of status. + $form_state->clearErrors(); + $form_state->setValue(['resource', 0, 'value'], 'reports'); + $form_state->setValue(['field_resource_status', 0, 'value'], 'test'); + $form_object->validateForm($form, $form_state); + $errors = $form_state->getErrors(); + $this->assertArrayNotHasKey('resource', $errors); + $this->assertArrayHasKey('field_resource_status', $errors); + $this->assertStringContainsString('is not supported for this resource, please select one of', (string) $errors['field_resource_status']); + } + /** * @covers ::save() */ From 2fee61e2211dc2ac4c00692a980c25c3dcc1f704 Mon Sep 17 00:00:00 2001 From: orakili Date: Tue, 5 Mar 2024 02:48:29 +0000 Subject: [PATCH 15/73] feat: add simple webhook system for the post api Refs: RW-831, RW-866 --- ...ntity_form_display.node.report.default.yml | 6 ++- ...ntity_view_display.node.report.default.yml | 12 +++-- ...tity_view_display.node.report.headline.yml | 2 + ...entity_view_display.node.report.teaser.yml | 2 + ...ld.node.report.field_post_api_provider.yml | 26 +++++++++ ...d.storage.node.field_post_api_provider.yml | 20 +++++++ .../reliefweb_post_api.module | 32 +++++++++++ .../src/Entity/Provider.php | 40 ++++++++++++++ .../ContentProcessor/Report.php | 3 ++ .../src/ExistingSite/Entity/ProviderTest.php | 53 +++++++++++++++++++ 10 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 config/field.field.node.report.field_post_api_provider.yml create mode 100644 config/field.storage.node.field_post_api_provider.yml create mode 100644 html/modules/custom/reliefweb_post_api/reliefweb_post_api.module diff --git a/config/core.entity_form_display.node.report.default.yml b/config/core.entity_form_display.node.report.default.yml index 88365be01..3398e3cb9 100644 --- a/config/core.entity_form_display.node.report.default.yml +++ b/config/core.entity_form_display.node.report.default.yml @@ -24,6 +24,7 @@ dependencies: - field.field.node.report.field_origin - field.field.node.report.field_origin_notes - field.field.node.report.field_original_publication_date + - field.field.node.report.field_post_api_provider - field.field.node.report.field_primary_country - field.field.node.report.field_source - field.field.node.report.field_theme @@ -34,8 +35,8 @@ dependencies: - datetime - inline_entity_form - path - - reliefweb_files - reliefweb_fields + - reliefweb_files - text id: node.report.default targetEntityType: node @@ -196,6 +197,7 @@ content: collapsible: false collapsed: false revision: false + removed_reference: optional third_party_settings: { } field_headline_summary: type: string_textarea @@ -229,6 +231,7 @@ content: collapsible: false collapsed: true revision: false + removed_reference: optional third_party_settings: { } field_language: type: options_buttons @@ -374,6 +377,7 @@ content: third_party_settings: { } hidden: created: true + field_post_api_provider: true field_vulnerable_groups: true langcode: true promote: true diff --git a/config/core.entity_view_display.node.report.default.yml b/config/core.entity_view_display.node.report.default.yml index bfb00fdb4..d5937ba6f 100644 --- a/config/core.entity_view_display.node.report.default.yml +++ b/config/core.entity_view_display.node.report.default.yml @@ -23,6 +23,7 @@ dependencies: - field.field.node.report.field_origin - field.field.node.report.field_origin_notes - field.field.node.report.field_original_publication_date + - field.field.node.report.field_post_api_provider - field.field.node.report.field_primary_country - field.field.node.report.field_source - field.field.node.report.field_theme @@ -72,7 +73,7 @@ content: settings: link: true third_party_settings: { } - weight: 10 + weight: 9 region: content field_disaster_type: type: entity_reference_label @@ -80,14 +81,14 @@ content: settings: link: true third_party_settings: { } - weight: 11 + weight: 10 region: content field_file: type: reliefweb_file label: above settings: { } third_party_settings: { } - weight: 13 + weight: 12 region: content field_image: type: entity_reference_entity_view @@ -104,7 +105,7 @@ content: settings: link: true third_party_settings: { } - weight: 12 + weight: 11 region: content field_origin_notes: type: string @@ -145,7 +146,7 @@ content: settings: link: true third_party_settings: { } - weight: 9 + weight: 8 region: content hidden: field_bury: true @@ -158,6 +159,7 @@ hidden: field_notify: true field_ocha_product: true field_origin: true + field_post_api_provider: true field_vulnerable_groups: true langcode: true links: true diff --git a/config/core.entity_view_display.node.report.headline.yml b/config/core.entity_view_display.node.report.headline.yml index 2bba2bd4b..af7ee3829 100644 --- a/config/core.entity_view_display.node.report.headline.yml +++ b/config/core.entity_view_display.node.report.headline.yml @@ -24,6 +24,7 @@ dependencies: - field.field.node.report.field_origin - field.field.node.report.field_origin_notes - field.field.node.report.field_original_publication_date + - field.field.node.report.field_post_api_provider - field.field.node.report.field_primary_country - field.field.node.report.field_source - field.field.node.report.field_theme @@ -106,6 +107,7 @@ hidden: field_origin: true field_origin_notes: true field_original_publication_date: true + field_post_api_provider: true field_theme: true field_vulnerable_groups: true langcode: true diff --git a/config/core.entity_view_display.node.report.teaser.yml b/config/core.entity_view_display.node.report.teaser.yml index b3f289937..ee2d1bab4 100644 --- a/config/core.entity_view_display.node.report.teaser.yml +++ b/config/core.entity_view_display.node.report.teaser.yml @@ -24,6 +24,7 @@ dependencies: - field.field.node.report.field_origin - field.field.node.report.field_origin_notes - field.field.node.report.field_original_publication_date + - field.field.node.report.field_post_api_provider - field.field.node.report.field_primary_country - field.field.node.report.field_source - field.field.node.report.field_theme @@ -109,6 +110,7 @@ hidden: field_ocha_product: true field_origin: true field_origin_notes: true + field_post_api_provider: true field_theme: true field_vulnerable_groups: true langcode: true diff --git a/config/field.field.node.report.field_post_api_provider.yml b/config/field.field.node.report.field_post_api_provider.yml new file mode 100644 index 000000000..b17da7a4b --- /dev/null +++ b/config/field.field.node.report.field_post_api_provider.yml @@ -0,0 +1,26 @@ +uuid: 1ea994b5-9a7a-4263-aaf6-5d4fdf3bc7cc +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_post_api_provider + - node.type.report +id: node.report.field_post_api_provider +field_name: field_post_api_provider +entity_type: node +bundle: report +label: 'POST API provider' +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:reliefweb_post_api_provider' + handler_settings: + target_bundles: null + sort: + field: _none + direction: ASC + auto_create: false +field_type: entity_reference diff --git a/config/field.storage.node.field_post_api_provider.yml b/config/field.storage.node.field_post_api_provider.yml new file mode 100644 index 000000000..4476f8d51 --- /dev/null +++ b/config/field.storage.node.field_post_api_provider.yml @@ -0,0 +1,20 @@ +uuid: 7993dd10-8926-4c2c-ae65-0d4527502749 +langcode: en +status: true +dependencies: + module: + - node + - reliefweb_post_api +id: node.field_post_api_provider +field_name: field_post_api_provider +entity_type: node +type: entity_reference +settings: + target_type: reliefweb_post_api_provider +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.module b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.module new file mode 100644 index 000000000..99c81e4c6 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.module @@ -0,0 +1,32 @@ +check($key, $this->key->value); } + /** + * Notify the provider of an entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * Entity that has been created, updated or deleted. + */ + public static function notifyProvider(EntityInterface $entity): void { + if ($entity instanceof ContentEntityInterface && $entity->hasField('field_post_api_provider')) { + $provider = $entity->field_post_api_provider->entity; + if (!empty($provider)) { + $client = \Drupal::httpClient(); + $timeout = \Drupal::state()->get('reliefweb_post_api.timeout', 1); + $logger = \Drupal::logger('reliefweb_post_api.webhook'); + + foreach ($provider->field_webhook_url as $item) { + if (!empty($item->uri)) { + $url = $item->uri . '/' . $entity->uuid(); + try { + $client->get($url, ['timeout' => $timeout]); + + $logger->info(strtr('Request sent to @url for provider @provider.', [ + '@url' => $url, + '@provider' => $provider->uuid(), + ])); + } + catch (\Exception $exception) { + $logger->notice(strtr('Request to @url for provider @provider failed: @error', [ + '@url' => $url, + '@provider' => $provider->uuid(), + '@error' => $exception->getMessage(), + ])); + } + } + } + } + } + } + } diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php index 280076ba4..c96a6d2d7 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php @@ -109,6 +109,9 @@ public function process(array $data): ?ContentEntityInterface { $node->field_feature->setValue(NULL); $node->field_ocha_product->setValue(NULL); + // Set the provider. + $this->setField($node, 'field_post_api_provider', $provider); + // Set the new status. $node->moderation_status = $provider->getDefaultResourceStatus(); diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php index 69e28f730..763e93b15 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php @@ -4,8 +4,11 @@ namespace Drupal\Tests\reliefweb_post_api\ExistingSite\Entity; +use Drupal\Core\Logger\LoggerChannelFactory; use Drupal\reliefweb_post_api\Entity\Provider; use Drupal\reliefweb_post_api\Entity\ProviderInterface; +use GuzzleHttp\Client; +use Symfony\Component\ErrorHandler\BufferingLogger; use weitzman\DrupalTestTraits\ExistingSiteBase; /** @@ -24,6 +27,7 @@ class ProviderTest extends ExistingSiteBase { */ protected $data = [ 'name' => 'test-provider', + 'uuid' => '7603a5e4-d168-4509-8979-f3c89c16f1f0', 'key' => 'test-provider-key', 'resource' => 'reports', 'field_document_url' => ['https://test.test/', 'https://test1.test/'], @@ -33,6 +37,7 @@ class ProviderTest extends ExistingSiteBase { 'field_source' => [1503], 'field_user' => 12, 'field_resource_status' => 'pending', + 'field_webhook_url' => 'https://test.test', ]; /** @@ -190,6 +195,54 @@ public function testValidateKey(): void { $this->assertFalse($provider->validateKey($this->data['key'])); } + /** + * @covers ::notifyProvider + */ + public function testNotifyProvider(): void { + $data = $this->data; + + $provider = $this->createProvider($data); + + $entity = \Drupal::entityTypeManager()->getStorage('node')->create([ + 'type' => 'report', + 'uuid' => '5fa67cc5-8c2a-4c05-aab2-bb61368ec3fb', + 'field_post_api_provider' => $provider, + ]); + + // Mock services. + $logger = new BufferingLogger(); + $logger_factory = $this->createConfiguredMock(LoggerChannelFactory::class, [ + 'get' => $logger, + ]); + $client = $this->createMock(Client::class); + + $container = \Drupal::getContainer(); + $container->set('logger.factory', $logger_factory); + $container->set('http_client', $client); + + Provider::notifyProvider($entity); + $message = strtr('Request sent to @url for provider @provider.', [ + '@url' => $provider->field_webhook_url->uri . '/' . $entity->uuid(), + '@provider' => $provider->uuid(), + ]); + $this->assertSame([ + ['info', $message, []], + ], $logger->cleanLogs()); + + $client->expects($this->any()) + ->method('get') + ->willThrowException(new \Exception('error')); + Provider::notifyProvider($entity); + $message = strtr('Request to @url for provider @provider failed: @error', [ + '@url' => $provider->field_webhook_url->uri . '/' . $entity->uuid(), + '@provider' => $provider->uuid(), + '@error' => 'error', + ]); + $this->assertSame([ + ['notice', $message, []], + ], $logger->cleanLogs()); + } + /** * Create a provider. * From 3d9e61d01526c3969872cd00ec466011d8477e28 Mon Sep 17 00:00:00 2001 From: orakili Date: Mon, 11 Mar 2024 02:51:49 +0000 Subject: [PATCH 16/73] chore: check that the appname parameter is present Refs: RW-831 --- .../src/Controller/ReliefWebPostApi.php | 5 +++ .../Controller/ReliefWebPostApiTest.php | 34 +++++++++++++------ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php index 7712c1068..11fe96a5e 100644 --- a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php +++ b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php @@ -70,6 +70,11 @@ public function postContent(string $resource, string $uuid): JsonResponse { $request = $this->requestStack->getCurrentRequest(); $headers = $request->headers; + // Check that the appname parameter is present. + if (empty($request->query->get('appname'))) { + throw new BadRequestHttpException('Missing or invalid appname parameter.'); + } + // Only PUT requests are allowed currently. // @todo handle PATCH and DELETE. if ($request->getMethod() !== 'PUT') { diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php index e826f7fe5..36856c184 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php @@ -10,6 +10,7 @@ use Drupal\reliefweb_post_api\Controller\ReliefWebPostApi; use Drupal\reliefweb_post_api\Entity\ProviderInterface; use Symfony\Component\HttpFoundation\HeaderBag; +use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Uid\Uuid; @@ -71,6 +72,26 @@ public function testPostContentUnknownException(): void { $this->assertStringContainsString('test exception', $response->getContent()); } + /** + * @covers ::postContent + */ + public function testPostContentMissingAppname(): void { + $request = $this->createMockRequest(methods: [ + 'getMethod' => 'GET', + ]); + $request->query->remove('appname'); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController([ + 'request_stack' => $request_stack, + ]); + + $response = $controller->postContent('reports', $this->getTestUuid()); + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('Missing or invalid appname parameter.', $response->getContent()); + } + /** * @covers ::postContent */ @@ -377,16 +398,6 @@ protected function createMockRequest(array $headers = [], array $methods = []): 'X-RW-POST-API-KEY' => 'test-provider-key', ]; - $header_map = []; - foreach ($headers as $key => $value) { - $header_map[] = [$key, '', $value]; - } - - $header_bag = $this->createMock(HeaderBag::class); - $header_bag->expects($this->any()) - ->method('get') - ->willReturnMap($header_map); - $methods += [ 'getMethod' => 'PUT', 'getContentTypeFormat' => 'json', @@ -394,7 +405,8 @@ protected function createMockRequest(array $headers = [], array $methods = []): ]; $request = $this->createConfiguredMock(Request::class, $methods); - $request->headers = $header_bag; + $request->headers = new HeaderBag($headers); + $request->query = new InputBag(['appname' => 'test']); return $request; } From f6f9972bb3422645fa9c81be6d3359bd25eaffc2 Mon Sep 17 00:00:00 2001 From: orakili Date: Mon, 11 Mar 2024 05:10:51 +0000 Subject: [PATCH 17/73] fix: no need to hit drupal for the post api json schemas Refs: RW-831 --- docker/etc/nginx/custom/04_api_schemas.conf | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docker/etc/nginx/custom/04_api_schemas.conf diff --git a/docker/etc/nginx/custom/04_api_schemas.conf b/docker/etc/nginx/custom/04_api_schemas.conf new file mode 100644 index 000000000..ebce3a04a --- /dev/null +++ b/docker/etc/nginx/custom/04_api_schemas.conf @@ -0,0 +1,13 @@ +## RW post API schemas. +location /post-api-schemas/v2/ { + + location ~ "^/post-api-schemas/v2/(?[a-z][a-z_-]+[a-z]\.json)$" { + ## Allow CORS so that the RW API swagger UI can load the schema. + add_header Access-Control-Allow-Origin '*' always; + add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS' always; + + try_files "/modules/custom/reliefweb_post_api/schemas/v2/$file_name" =404; + } + + return 404; +} From 277b296efd31908eb8ffd637e7b507b287640213 Mon Sep 17 00:00:00 2001 From: orakili Date: Thu, 14 Mar 2024 09:50:32 +0000 Subject: [PATCH 18/73] feat: handle rate limiting Refs: RW-831 --- ...er.reliefweb_post_api_provider.default.yml | 22 ++- ...er.reliefweb_post_api_provider.default.yml | 22 ++- ...eliefweb_post_api_provider.field_quota.yml | 26 +++ ...web_post_api_provider.field_rate_limit.yml | 26 +++ ...eliefweb_post_api_provider.field_quota.yml | 20 ++ ...web_post_api_provider.field_rate_limit.yml | 20 ++ .../reliefweb_post_api.install | 55 ++++++ .../src/Controller/ReliefWebPostApi.php | 87 +++++++++ .../src/Entity/Provider.php | 28 +++ .../src/Entity/ProviderInterface.php | 16 ++ .../Controller/ReliefWebPostApiTest.php | 171 +++++++++++++++++- .../src/ExistingSite/Entity/ProviderTest.php | 50 +++++ 12 files changed, 537 insertions(+), 6 deletions(-) create mode 100644 config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_quota.yml create mode 100644 config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_rate_limit.yml create mode 100644 config/field.storage.reliefweb_post_api_provider.field_quota.yml create mode 100644 config/field.storage.reliefweb_post_api_provider.field_rate_limit.yml create mode 100644 html/modules/custom/reliefweb_post_api/reliefweb_post_api.install diff --git a/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml b/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml index b9ca199e5..26e5179f7 100644 --- a/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml +++ b/config/core.entity_form_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml @@ -7,6 +7,8 @@ dependencies: - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_quota + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_rate_limit - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_resource_status - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_source - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_user @@ -46,12 +48,26 @@ content: third_party_settings: { } field_notify: type: email_default - weight: 9 + weight: 11 region: content settings: placeholder: '' size: 60 third_party_settings: { } + field_quota: + type: number + weight: 9 + region: content + settings: + placeholder: '' + third_party_settings: { } + field_rate_limit: + type: number + weight: 10 + region: content + settings: + placeholder: '' + third_party_settings: { } field_resource_status: type: options_select weight: 2 @@ -114,7 +130,7 @@ content: third_party_settings: { } field_webhook_url: type: link_default - weight: 10 + weight: 12 region: content settings: placeholder_url: '' @@ -144,7 +160,7 @@ content: third_party_settings: { } status: type: boolean_checkbox - weight: 11 + weight: 13 region: content settings: display_label: true diff --git a/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml b/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml index 37c7f08c2..fb5a0b4c6 100644 --- a/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml +++ b/config/core.entity_view_display.reliefweb_post_api_provider.reliefweb_post_api_provider.default.yml @@ -7,6 +7,8 @@ dependencies: - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_file_url - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_image_url - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_notify + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_quota + - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_rate_limit - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_resource_status - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_source - field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_user @@ -61,8 +63,26 @@ content: label: above settings: { } third_party_settings: { } + weight: 10 + region: content + field_quota: + type: number_integer + label: above + settings: + thousand_separator: '' + prefix_suffix: true + third_party_settings: { } weight: 8 region: content + field_rate_limit: + type: number_integer + label: above + settings: + thousand_separator: '' + prefix_suffix: true + third_party_settings: { } + weight: 9 + region: content field_resource_status: type: list_default label: above @@ -96,7 +116,7 @@ content: rel: '' target: '' third_party_settings: { } - weight: 9 + weight: 11 region: content name: type: string diff --git a/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_quota.yml b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_quota.yml new file mode 100644 index 000000000..c19dab612 --- /dev/null +++ b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_quota.yml @@ -0,0 +1,26 @@ +uuid: c3c92661-6018-4152-86a2-c1d2fa1da84e +langcode: en +status: true +dependencies: + config: + - field.storage.reliefweb_post_api_provider.field_quota + module: + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.field_quota +field_name: field_quota +entity_type: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +label: 'Request quota' +description: 'Number of allowed requests per day.' +required: true +translatable: false +default_value: + - + value: 50 +default_value_callback: '' +settings: + min: 1 + max: null + prefix: '' + suffix: '' +field_type: integer diff --git a/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_rate_limit.yml b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_rate_limit.yml new file mode 100644 index 000000000..67dc6b987 --- /dev/null +++ b/config/field.field.reliefweb_post_api_provider.reliefweb_post_api_provider.field_rate_limit.yml @@ -0,0 +1,26 @@ +uuid: 2631a8bc-7cee-45dc-a8c9-6d8739cfab41 +langcode: en +status: true +dependencies: + config: + - field.storage.reliefweb_post_api_provider.field_rate_limit + module: + - reliefweb_post_api +id: reliefweb_post_api_provider.reliefweb_post_api_provider.field_rate_limit +field_name: field_rate_limit +entity_type: reliefweb_post_api_provider +bundle: reliefweb_post_api_provider +label: 'Request rate limit' +description: 'Minimum duration in seconds between 2 requests.' +required: true +translatable: false +default_value: + - + value: 60 +default_value_callback: '' +settings: + min: 1 + max: null + prefix: '' + suffix: '' +field_type: integer diff --git a/config/field.storage.reliefweb_post_api_provider.field_quota.yml b/config/field.storage.reliefweb_post_api_provider.field_quota.yml new file mode 100644 index 000000000..6b1724228 --- /dev/null +++ b/config/field.storage.reliefweb_post_api_provider.field_quota.yml @@ -0,0 +1,20 @@ +uuid: c8b15b59-ea13-41d9-9d5c-4386a94f345f +langcode: en +status: true +dependencies: + module: + - reliefweb_post_api +id: reliefweb_post_api_provider.field_quota +field_name: field_quota +entity_type: reliefweb_post_api_provider +type: integer +settings: + unsigned: false + size: normal +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/field.storage.reliefweb_post_api_provider.field_rate_limit.yml b/config/field.storage.reliefweb_post_api_provider.field_rate_limit.yml new file mode 100644 index 000000000..1dff1d190 --- /dev/null +++ b/config/field.storage.reliefweb_post_api_provider.field_rate_limit.yml @@ -0,0 +1,20 @@ +uuid: 20aba504-c4b4-496e-b138-5d60cf68e14b +langcode: en +status: true +dependencies: + module: + - reliefweb_post_api +id: reliefweb_post_api_provider.field_rate_limit +field_name: field_rate_limit +entity_type: reliefweb_post_api_provider +type: integer +settings: + unsigned: false + size: normal +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/html/modules/custom/reliefweb_post_api/reliefweb_post_api.install b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.install new file mode 100644 index 000000000..a5032207f --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/reliefweb_post_api.install @@ -0,0 +1,55 @@ + [ + 'provider_id' => [ + 'description' => 'Provider ID.', + 'type' => 'int', + 'unsigned' => TRUE, + 'size' => 'normal', + 'not null' => TRUE, + 'default' => 0, + ], + 'request_count' => [ + 'description' => 'Number of requests since last reset (daily).', + 'type' => 'int', + 'unsigned' => TRUE, + 'size' => 'normal', + 'not null' => FALSE, + 'default' => 0, + ], + 'last_request_time' => [ + 'description' => 'Timestamp of the last request.', + 'type' => 'int', + 'unsigned' => TRUE, + 'size' => 'normal', + 'not null' => FALSE, + 'default' => 0, + ], + ], + 'primary key' => ['provider_id'], + ]; + + return $schema; +} + +/** + * Implements hook_update_N(). + * + * Add the rate limits table. + */ +function reliefweb_post_api_update_10001(array &$sandbox) { + $schema = \Drupal::database()->schema(); + if (!$schema->tableExists('reliefweb_post_api_rate_limit')) { + $schema->createTable('reliefweb_post_api_rate_limit', reliefweb_post_api_schema()['reliefweb_post_api_rate_limit']); + } +} diff --git a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php index 11fe96a5e..f0ecc1e4b 100644 --- a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php +++ b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php @@ -4,9 +4,12 @@ namespace Drupal\reliefweb_post_api\Controller; +use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Database\Connection; use Drupal\Core\Extension\ExtensionPathResolver; use Drupal\Core\Queue\QueueFactory; +use Drupal\reliefweb_post_api\Entity\ProviderInterface; use Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; @@ -16,6 +19,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\Uid\Uuid; /** @@ -32,6 +36,10 @@ class ReliefWebPostApi extends ControllerBase { * The queue factory. * @param \Drupal\Core\Extension\ExtensionPathResolver $pathResolver * The path resolver service. + * @param \Drupal\Core\Database\Connection $database + * The database connection. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. * @param \Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface $contentProcessorPluginManager * The ReliefWeb POST API content processor plugin manager. */ @@ -39,6 +47,8 @@ public function __construct( protected RequestStack $requestStack, protected QueueFactory $queueFactory, protected ExtensionPathResolver $pathResolver, + protected Connection $database, + protected TimeInterface $time, protected ContentProcessorPluginManagerInterface $contentProcessorPluginManager ) {} @@ -50,6 +60,8 @@ public static function create(ContainerInterface $container) { $container->get('request_stack'), $container->get('queue'), $container->get('extension.path.resolver'), + $container->get('database'), + $container->get('datetime.time'), $container->get('plugin.manager.reliefweb_post_api.content_processor') ); } @@ -113,6 +125,9 @@ public function postContent(string $resource, string $uuid): JsonResponse { throw new AccessDeniedHttpException('Invalid API key.'); } + // Check the rate limits. + $this->checkRateLimits($provider); + // Check if we received JSON data. if ($request->getContentTypeFormat() !== 'json') { throw new BadRequestHttpException('Invalid content format.'); @@ -173,6 +188,78 @@ public function postContent(string $resource, string $uuid): JsonResponse { return $response; } + /** + * Check the request rate limit for a provider. + * + * @param \Drupal\reliefweb_post_api\Entity\ProviderInterface $provider + * Provider. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + * An HTTP exception (ex: 500 or 429) if there is an issue like missing + * field or the rate limit/quota is exceeded. + */ + public function checkRateLimits(ProviderInterface $provider) { + $quota = $provider->getQuota(); + $rate_limit = $provider->getRateLimit(); + + if (empty($quota)) { + throw new AccessDeniedHttpException('Not allowed to post content.'); + } + + $info = $this->database + ->select('reliefweb_post_api_rate_limits') + ->condition('provider_id', $provider->id()) + ->execute() + ?->fetchAssoc() ?? []; + + $request_time = $this->time->getRequestTime(); + $last_request_time = min($info['last_request_time'] ?? $request_time, $request_time); + + try { + $request_date = new \DateTime('@' . $request_time, new \DateTimeZone('UTC')); + $last_request_date = new \DateTime('@' . $last_request_time, new \DateTimeZone('UTC')); + $diff = $request_date->diff($last_request_date); + } + catch (\Exception $exception) { + throw new HttpException(500, 'Internal server error.'); + } + + $seconds_since_last_request = $diff->days * 24 * 60 * 60; + $seconds_since_last_request += $diff->h * 60 * 60; + $seconds_since_last_request += $diff->i * 60; + $seconds_since_last_request += $diff->s; + + // Not enough time since last request. + if ($seconds_since_last_request < $rate_limit) { + throw new TooManyRequestsHttpException($rate_limit - $seconds_since_last_request, 'Not enough time ellapsed since last request.'); + } + + $same_day = $diff->d === 0; + + // Already performed the maximum daily number of requests. + $request_count = $info['request_count'] ?? 0; + if ($same_day && $request_count >= $quota) { + // Next valid time to retry is the next day (UTC). + $date = $request_date + ->add(new \DateInterval('P1D')) + ->setTime(0, 0, 1) + ->format(\DateTimeInterface::RFC7231); + throw new TooManyRequestsHttpException($date, 'Daily quota exceeded.'); + } + + // If the request is valid, update the database. + $this->database + ->upsert('reliefweb_post_api_rate_limits') + ->fields(['request_count', 'last_request_time']) + ->key('provider_id') + ->values([ + 'provider_id' => $provider->id(), + 'request_count' => $same_day ? $request_count + 1 : 1, + 'last_request_time' => $request_time, + ]) + ->execute(); + } + /** * Get a JSON schema. * diff --git a/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php b/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php index 264a80ab7..58ef65736 100644 --- a/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php +++ b/html/modules/custom/reliefweb_post_api/src/Entity/Provider.php @@ -220,6 +220,34 @@ public function getDefaultResourceStatus(): string { return $this->get($field)->value; } + /** + * {@inheritdoc} + */ + public function getQuota(): int { + $field = 'field_quota'; + if (!$this->hasField($field)) { + return 0; + } + if (empty($this->get($field)->value)) { + return 0; + } + return (int) $this->get($field)->value; + } + + /** + * {@inheritdoc} + */ + public function getRateLimit(): int { + $field = 'field_rate_limit'; + if (!$this->hasField($field)) { + return 0; + } + if (empty($this->get($field)->value)) { + return 0; + } + return (int) $this->get($field)->value; + } + /** * {@inheritdoc} */ diff --git a/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php b/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php index f002b5f1c..c98743607 100644 --- a/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php +++ b/html/modules/custom/reliefweb_post_api/src/Entity/ProviderInterface.php @@ -54,6 +54,22 @@ public function getUserId(): int; */ public function getDefaultResourceStatus(): string; + /** + * Get the daily request quota. + * + * @return int + * Daily quota. + */ + public function getQuota(): int; + + /** + * Get the minimum amount of time between 2 requests. + * + * @return int + * Rate limit. + */ + public function getRateLimit(): int; + /** * Check if a provider with the given ID exits and its API key is valid. * diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php index 36856c184..e39afe135 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php @@ -4,6 +4,11 @@ namespace Drupal\Tests\reliefweb_post_api\ExistingSite\Controller; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Database\Connection; +use Drupal\Core\Database\Query\SelectInterface; +use Drupal\Core\Database\Query\Upsert; +use Drupal\Core\Database\StatementInterface; use Drupal\Core\Extension\ExtensionPathResolver; use Drupal\Core\Queue\QueueFactory; use Drupal\Core\Queue\QueueInterface; @@ -13,6 +18,9 @@ use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\Uid\Uuid; use weitzman\DrupalTestTraits\ExistingSiteBase; @@ -200,6 +208,119 @@ public function testPostContentUnknownEndpoint(): void { $this->assertStringContainsString('Unknown endpoint.', $response->getContent()); } + /** + * @covers ::checkRateLimits() + */ + public function testCheckRateLimitsNoQuota(): void { + $provider = $this->createConfiguredMock(ProviderInterface::class, [ + 'id' => 123, + 'getQuota' => 0, + 'getRateLimit' => 0, + ]); + + $controller = $this->createTestController(); + + $this->expectException(AccessDeniedHttpException::class); + $controller->checkRateLimits($provider); + } + + /** + * @covers ::checkRateLimits() + */ + public function testCheckRateLimitsInvalidTimestamp(): void { + $provider = $this->createConfiguredMock(ProviderInterface::class, [ + 'id' => 123, + 'getQuota' => 1, + 'getRateLimit' => 1, + ]); + + $time = $this->createConfiguredMock(TimeInterface::class, [ + 'getRequestTime' => 'wrong_timestamp', + ]); + + $controller = $this->createTestController(services: [ + 'datetime.time' => $time, + ]); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Internal server error.'); + $controller->checkRateLimits($provider); + } + + /** + * @covers ::checkRateLimits() + */ + public function testCheckRateLimitsRateLimitExceeded(): void { + $provider = $this->createConfiguredMock(ProviderInterface::class, [ + 'id' => 123, + 'getQuota' => 1, + 'getRateLimit' => 60, + ]); + + $now = strtotime('2024-02-02T02:02:02+00:00'); + + $rate_limit_info = [ + 'provider_id' => 123, + 'request_count' => 0, + 'last_request_time' => $now - 1, + ]; + + $controller = $this->createTestController(rate_limit_info: $rate_limit_info, now: $now); + + $this->expectException(TooManyRequestsHttpException::class); + $this->expectExceptionMessage('Not enough time ellapsed since last request.'); + $controller->checkRateLimits($provider); + } + + /** + * @covers ::checkRateLimits() + */ + public function testCheckRateLimitsDailyQuotaExceeded(): void { + $provider = $this->createConfiguredMock(ProviderInterface::class, [ + 'id' => 123, + 'getQuota' => 1, + 'getRateLimit' => 60, + ]); + + $now = strtotime('2024-02-02T02:02:02+00:00'); + + $rate_limit_info = [ + 'provider_id' => 123, + 'request_count' => 2, + 'last_request_time' => $now - 120, + ]; + + $controller = $this->createTestController(rate_limit_info: $rate_limit_info, now: $now); + + $this->expectException(TooManyRequestsHttpException::class); + $this->expectExceptionMessage('Daily quota exceeded.'); + $controller->checkRateLimits($provider); + } + + /** + * @covers ::checkRateLimits() + */ + public function testCheckRateLimits(): void { + $provider = $this->createConfiguredMock(ProviderInterface::class, [ + 'id' => 123, + 'getQuota' => 1, + 'getRateLimit' => 60, + ]); + + $now = strtotime('2024-02-02T02:02:02+00:00'); + + $rate_limit_info = [ + 'provider_id' => 123, + 'request_count' => 0, + 'last_request_time' => $now - 120, + ]; + + $controller = $this->createTestController(rate_limit_info: $rate_limit_info, now: $now); + + $controller->checkRateLimits($provider); + $this->assertTrue(TRUE); + } + /** * @covers ::postContent */ @@ -431,17 +552,61 @@ protected function createMockRequestStack(Request $request): RequestStack { * * @param array $services * List of services. + * @param array $rate_limit_info + * The rate limit info as returned from the database. + * @param int|null $now + * Current unix time. * * @return \Drupal\reliefweb_post_api\Controller\ReliefWebPostApi * The test controller. */ - protected function createTestController(array $services = []): ReliefWebPostApi { + protected function createTestController(array $services = [], array $rate_limit_info = [], ?int $now = NULL): ReliefWebPostApi { $container = \drupal::getContainer(); + $now = $now ?? time(); + + if (!isset($services['database'])) { + $rate_limit_info += [ + 'provider_id' => 123, + 'request_count' => 0, + 'last_request_time' => $now - 120, + ]; + + $statement = $this->createConfiguredMock(StatementInterface::class, [ + 'fetchAssoc' => $rate_limit_info, + ]); + + $select = $this->createConfiguredMock(SelectInterface::class, [ + 'fields' => $this->returnSelf(), + 'condition' => $this->returnSelf(), + 'execute' => $statement, + ]); + + $upsert = $this->createConfiguredMock(Upsert::class, [ + 'fields' => $this->returnSelf(), + 'key' => $this->returnSelf(), + 'values' => $this->returnSelf(), + 'execute' => 1, + ]); + + $services['database'] = $this->createConfiguredMock(Connection::class, [ + 'select' => $select, + 'upsert' => $upsert, + ]); + } + + if (!isset($services['datetime.time'])) { + $services['datetime.time'] = $this->createConfiguredMock(TimeInterface::class, [ + 'getRequestTime' => $now, + ]); + } + $services = [ $services['request_stack'] ?? $container->get('request_stack'), $services['queue'] ?? $container->get('queue'), $services['extension.path.resolver'] ?? $container->get('extension.path.resolver'), + $services['database'] ?? $container->get('database'), + $services['datetime.time'] ?? $container->get('datetime.time'), $services['plugin.manager.reliefweb_post_api.content_processor'] ?? $container->get('plugin.manager.reliefweb_post_api.content_processor'), ]; @@ -509,10 +674,12 @@ protected function getTestProvider(string $name = 'test-provider'): ?ProviderInt 'field_document_url' => ['https://test.test/'], 'field_file_url' => ['https://test.test/'], 'field_image_url' => ['https://test.test/'], + 'field_quota' => 2, + 'field_rate_limit' => 2, ]); $provider->save(); $this->markEntityForCleanup($provider); - $this->providers['test-provider'] = $provider; + $this->providers[$name] = $provider; } return $this->providers[$name] ?? NULL; } diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php index 763e93b15..2613dc554 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Entity/ProviderTest.php @@ -38,6 +38,8 @@ class ProviderTest extends ExistingSiteBase { 'field_user' => 12, 'field_resource_status' => 'pending', 'field_webhook_url' => 'https://test.test', + 'field_quota' => 50, + 'field_rate_limit' => 60, ]; /** @@ -176,6 +178,54 @@ public function testGetDefaultResourceStatus(): void { $this->assertEquals('draft', $provider->getDefaultResourceStatus()); } + /** + * @covers ::getQuota + */ + public function testGetQuota(): void { + $data = $this->data; + + $provider = $this->createProvider($data); + $this->assertEquals($this->data['field_quota'], $provider->getQuota()); + + $data['field_quota'] = []; + $provider = $this->createProvider($data); + $this->assertEquals(0, $provider->getQuota()); + + // Default field value (set in the UI). + unset($data['field_quota']); + $provider = $this->createProvider($data); + $this->assertEquals(50, $provider->getQuota()); + + // Missing field. + $provider = $this->createNoFieldProvider(); + $this->assertFalse($provider->hasField('field_quota')); + $this->assertEquals(0, $provider->getQuota()); + } + + /** + * @covers ::getRateLimit + */ + public function testRateLimit(): void { + $data = $this->data; + + $provider = $this->createProvider($data); + $this->assertEquals($this->data['field_rate_limit'], $provider->getRateLimit()); + + $data['field_rate_limit'] = []; + $provider = $this->createProvider($data); + $this->assertEquals(0, $provider->getRateLimit()); + + // Default field value (set in the UI). + unset($data['field_rate_limit']); + $provider = $this->createProvider($data); + $this->assertEquals(60, $provider->getRateLimit()); + + // Missing field. + $provider = $this->createNoFieldProvider(); + $this->assertFalse($provider->hasField('field_rate_limit')); + $this->assertEquals(0, $provider->getRateLimit()); + } + /** * @covers ::validateKey */ From d7c13b65caab195fb7cb04effd671ddae96d397b Mon Sep 17 00:00:00 2001 From: orakili Date: Tue, 2 Apr 2024 03:14:54 +0000 Subject: [PATCH 19/73] fix: allow unicode URLs and text Refs: RW-831 --- .../reliefweb_post_api/schemas/v2/report.json | 40 +++++++++++-------- .../src/Controller/ReliefWebPostApi.php | 9 +++-- .../tests/data/data-report-unicode.json | 36 +++++++++++++++++ .../Plugin/ContentProcessorPluginBaseTest.php | 13 ++++++ 4 files changed, 77 insertions(+), 21 deletions(-) create mode 100644 html/modules/custom/reliefweb_post_api/tests/data/data-report-unicode.json diff --git a/html/modules/custom/reliefweb_post_api/schemas/v2/report.json b/html/modules/custom/reliefweb_post_api/schemas/v2/report.json index 9462dde50..e858b5570 100644 --- a/html/modules/custom/reliefweb_post_api/schemas/v2/report.json +++ b/html/modules/custom/reliefweb_post_api/schemas/v2/report.json @@ -7,7 +7,7 @@ "url": { "description": "Unique URL to identify the document. Use the original canonical of the document if available.", "type": "string", - "format": "uri", + "format": "iri", "maxLength": 2048 }, "uuid": { @@ -27,12 +27,12 @@ }, { "description": "No control characters or separators except for spaces.", - "pattern": "^([^\\p{Z}\\p{C}]|[ ])+$" + "pattern": "^([^\\p{Z}\\p{C}]|[ \u3000])+$" } ], "not": { "description": "No leading, trailing or consecutive spaces.", - "pattern": "(?:^[ ]|[ ]$|[ ]{2,})" + "pattern": "(?:^[ \u3000]|[ \u3000]$|[ \u3000]{2,})" } }, "source": { @@ -88,12 +88,12 @@ }, { "description": "No control characters or separators except for spaces and new lines.", - "pattern": "^(?:[^\\p{Z}\\p{C}]|[ \\n])+$" + "pattern": "^(?:[^\\p{Z}\\p{C}]|[ \u3000\\n])+$" } ], "not": { "description": "No leading, trailing or consecutive spaces and new lines (except at the end of a line to support markdown linebreaks).", - "pattern": "(?:^[ \\n]|[ \\n]$|[ ]{2,}[^ \\n])" + "pattern": "(?:^[ \u3000\\n]|[ \u3000\\n]$|[ \u3000]{2,}[^ \u3000\\n])" } }, "embargoed": { @@ -104,7 +104,7 @@ "origin": { "description": "Original canonical URL of the document if available, in which case it should be the same as the 'url' property.", "type": "string", - "format": "uri", + "format": "iri", "maxLength": 2048 }, "file": { @@ -116,7 +116,7 @@ "url": { "description": "URL to a PDF file.", "type": "string", - "format": "uri", + "format": "iri", "pattern": "\\.pdf$" }, "checksum": { @@ -135,12 +135,12 @@ }, { "description": "No control characters or separators except for spaces.", - "pattern": "^([^\\p{Z}\\p{C}]|[ ])+$" + "pattern": "^([^\\p{Z}\\p{C}]|[ \u3000])+$" } ], "not": { "description": "No leading, trailing or consecutive spaces.", - "pattern": "(?:^[ ]|[ ]$|[ ]{2,})" + "pattern": "(?:^[ \u3000]|[ \u3000]$|[ \u3000]{2,})" } }, "language": { @@ -159,7 +159,7 @@ "properties": { "url": { "type": "string", - "format": "uri", + "format": "iri", "pattern": "\\.(jpg|png|webp)$" }, "checksum": { @@ -178,12 +178,12 @@ }, { "description": "No control characters or separators except for spaces.", - "pattern": "^([^\\p{Z}\\p{C}]|[ ])+$" + "pattern": "^([^\\p{Z}\\p{C}]|[ \u3000])+$" } ], "not": { "description": "No leading, trailing or consecutive spaces.", - "pattern": "(?:^[ ]|[ ]$|[ ]{2,})" + "pattern": "(?:^[ \u3000]|[ \u3000]$|[ \u3000]{2,})" } }, "copyright": { @@ -197,12 +197,12 @@ }, { "description": "No control characters or separators except for spaces.", - "pattern": "^([^\\p{Z}\\p{C}]|[ ])+$" + "pattern": "^([^\\p{Z}\\p{C}]|[ \u3000])+$" } ], "not": { "description": "No leading, trailing or consecutive spaces.", - "pattern": "(?:^[ ]|[ ]$|[ ]{2,})" + "pattern": "(?:^[ \u3000]|[ \u3000]$|[ \u3000]{2,})" } } }, @@ -214,7 +214,6 @@ "items": { "type": "integer" }, - "minItems": 1, "maxItems": 30 }, "disaster_type": { @@ -223,7 +222,6 @@ "items": { "type": "integer" }, - "minItems": 1, "maxItems": 30 }, "theme": { @@ -232,8 +230,16 @@ "items": { "type": "integer" }, - "minItems": 1, "maxItems": 20 + }, + "notify": { + "description": "List of email addresses to notify when the document is published.", + "type": "array", + "items": { + "type": "string", + "format": "idn-email" + }, + "maxItems": 10 } }, "required": [ diff --git a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php index f0ecc1e4b..b085e1d11 100644 --- a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php +++ b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php @@ -207,13 +207,14 @@ public function checkRateLimits(ProviderInterface $provider) { } $info = $this->database - ->select('reliefweb_post_api_rate_limits') - ->condition('provider_id', $provider->id()) + ->select('reliefweb_post_api_rate_limit', 't') + ->fields('t', ['provider_id', 'request_count', 'last_request_time']) + ->condition('t.provider_id', $provider->id()) ->execute() ?->fetchAssoc() ?? []; $request_time = $this->time->getRequestTime(); - $last_request_time = min($info['last_request_time'] ?? $request_time, $request_time); + $last_request_time = min($info['last_request_time'] ?? $request_time - $rate_limit, $request_time); try { $request_date = new \DateTime('@' . $request_time, new \DateTimeZone('UTC')); @@ -249,7 +250,7 @@ public function checkRateLimits(ProviderInterface $provider) { // If the request is valid, update the database. $this->database - ->upsert('reliefweb_post_api_rate_limits') + ->upsert('reliefweb_post_api_rate_limit') ->fields(['request_count', 'last_request_time']) ->key('provider_id') ->values([ diff --git a/html/modules/custom/reliefweb_post_api/tests/data/data-report-unicode.json b/html/modules/custom/reliefweb_post_api/tests/data/data-report-unicode.json new file mode 100644 index 000000000..608b047de --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/tests/data/data-report-unicode.json @@ -0,0 +1,36 @@ +{ + "url": "https://テスト.テスト/test/report", + "uuid": "22ce51c2-d7ed-59b2-b71a-23fb56685df9", + "title": "This a テスト document - report", + "source": [1503], + "country": [13, 14], + "format": [10], + "language": [267, 268], + "published": "2024-06-06T01:00:00+00:00", + "body": "This is the テスト **body** text in markdown format.\n\nIt has some paragraphs \nand a line break.\n

TITLE

paragraph 1 bold

  • item1
  • item2

paragraph 2 link

. \u064a\u0639\u0631\u0641 \u0645\u0634\u063a\u0644\u0648 \u0627\u0644\u0631\u0633\u0645 \u0648\u0627\u0644\u0637\u0628\u0627\u0639\u0629 \u0647\u0630\u0627 \u062c\u064a\u062f\u064b\u0627 \u060c \u0641\u064a \u0627\u0644\u0648\u0627\u0642\u0639 \u060c \u062c\u0645\u064a\u0639 \u0627\u0644\u0645\u0647\u0646 \u0627\u0644\u062a\u064a \u062a\u062a\u0639\u0627\u0645\u0644 \u0645\u0639 \u0639\u0627\u0644\u0645 \u0627\u0644\u0627\u062a\u0635\u0627\u0644\u0627\u062a \u0644\u0647\u0627 \u0639\u0644\u0627\u0642\u0629 \u0645\u0633\u062a\u0642\u0631\u0629 \u0628\u0647\u0630\u0647 \u0627\u0644\u0643\u0644\u0645\u0627\u062a \u060c \u0648\u0644\u0643\u0646 \u0645\u0627 \u0647\u064a\u061f Lorem ipsum \u0646\u0635 \u0648\u0647\u0645\u064a \u0628\u062f\u0648\u0646 \u0623\u064a \u0645\u0639\u0646\u0649.\n\n\u0625\u0646\u0647\u0627 \u0633\u0644\u0633\u0644\u0629 \u0645\u0646 \u0627\u0644\u0643\u0644\u0645\u0627\u062a \u0627\u0644\u0644\u0627\u062a\u064a\u0646\u064a\u0629 \u0627\u0644\u062a\u064a \u060c \u0639\u0646\u062f \u0648\u0636\u0639\u0647\u0627 \u0641\u064a \u0645\u0648\u0636\u0639\u0647\u0627 \u060c \u0644\u0627 \u062a\u0634\u0643\u0644 \u062c\u0645\u0644\u064b\u0627 \u0628\u0645\u0639\u0646\u0649 \u0643\u0627\u0645\u0644 \u060c \u0648\u0644\u0643\u0646\u0647\u0627 \u062a\u0639\u0637\u064a \u0627\u0644\u062d\u064a\u0627\u0629 \u0644\u0646\u0635 \u0627\u062e\u062a\u0628\u0627\u0631 \u0645\u0641\u064a\u062f \u0644\u0645\u0644\u0621 \u0627\u0644\u0641\u0631\u0627\u063a\u0627\u062a \u0627\u0644\u062a\u064a \u064a\u062a\u0645 \u0634\u063a\u0644\u0647\u0627 \u0644\u0627\u062d\u0642\u064b\u0627 \u0645\u0646 \u0646\u0635\u0648\u0635 \u0645\u062e\u0635\u0635\u0629 \u0643\u062a\u0628\u0647\u0627 \u0645\u062a\u062e\u0635\u0635\u0648\u0646 \u0641\u064a \u0627\u0644\u0627\u062a\u0635\u0627\u0644.\n\n\u0625\u0646\u0647 \u0628\u0627\u0644\u062a\u0623\u0643\u064a\u062f \u0623\u0634\u0647\u0631 \u0646\u0635 \u0646\u0627\u0626\u0628 \u062d\u062a\u0649 \u0625\u0630\u0627 \u0643\u0627\u0646\u062a \u0647\u0646\u0627\u0643 \u0625\u0635\u062f\u0627\u0631\u0627\u062a \u0645\u062e\u062a\u0644\u0641\u0629 \u064a\u0645\u0643\u0646 \u062a\u0645\u064a\u064a\u0632\u0647\u0627 \u0639\u0646 \u062a\u0631\u062a\u064a\u0628 \u062a\u0643\u0631\u0627\u0631 \u0627\u0644\u0643\u0644\u0645\u0627\u062a \u0627\u0644\u0644\u0627\u062a\u064a\u0646\u064a\u0629.\n\n\u064a\u062d\u062a\u0648\u064a Lorem ipsum \u0639\u0644\u0649 \u0627\u0644\u0645\u062d\u0627\u0631\u0641 \u0627\u0644\u0623\u0643\u062b\u0631 \u0627\u0633\u062a\u062e\u062f\u0627\u0645\u064b\u0627 \u060c \u0648\u0647\u0648 \u062c\u0627\u0646\u0628 \u064a\u062a\u064a\u062d \u0644\u0643 \u0627\u0644\u062d\u0635\u0648\u0644 \u0639\u0644\u0649 \u0646\u0638\u0631\u0629 \u0639\u0627\u0645\u0629 \u0639\u0644\u0649 \u0639\u0631\u0636 \u0627\u0644\u0646\u0635 \u0645\u0646 \u062d\u064a\u062b \u0627\u062e\u062a\u064a\u0627\u0631 \u0627\u0644\u062e\u0637 \u0648 \u062d\u062c\u0645 \u0627\u0644\u062e\u0637 .\n\n\u0639\u0646\u062f \u0627\u0644\u0625\u0634\u0627\u0631\u0629 \u0625\u0644\u0649 Lorem ipsum \u060c \u064a\u062a\u0645 \u0627\u0633\u062a\u062e\u062f\u0627\u0645 \u062a\u0639\u0628\u064a\u0631\u0627\u062a \u0645\u062e\u062a\u0644\u0641\u0629 \u060c \u0648\u0628\u0627\u0644\u062a\u062d\u062f\u064a\u062f \u0645\u0644\u0621 \u0627\u0644\u0646\u0635 \u0623\u0648 \u0646\u0635 \u0648\u0647\u0645\u064a \u0623\u0648 \u0646\u0635 \u0645\u062e\u0641\u064a \u0623\u0648 < \u0642\u0648\u064a> \u0646\u0635 \u0639\u0646\u0635\u0631 \u0646\u0627\u0626\u0628 : \u0628\u0627\u062e\u062a\u0635\u0627\u0631 \u060c \u064a\u0645\u0643\u0646 \u0623\u0646 \u064a\u0643\u0648\u0646 \u0645\u0639\u0646\u0627\u0647 \u0623\u064a\u0636\u064b\u0627 \u0635\u0641\u0631\u064a\u064b\u0627 \u060c \u0648\u0644\u0643\u0646 \u0641\u0627\u0626\u062f\u062a\u0647 \u0648\u0627\u0636\u062d\u0629 \u062c\u062f\u064b\u0627 \u0628\u062d\u064a\u062b \u062a\u0633\u062a\u0645\u0631 \u0639\u0628\u0631 \u0627\u0644\u0642\u0631\u0648\u0646 \u0648\u062a\u0642\u0627\u0648\u0645 \u0627\u0644\u0625\u0635\u062f\u0627\u0631\u0627\u062a \u0627\u0644\u0633\u062e\u0631\u064a\u0629 \u0648\u0627\u0644\u062d\u062f\u064a\u062b\u0629 \u0627\u0644\u062a\u064a \u062c\u0627\u0621\u062a \u0645\u0639 \u0648\u0635\u0648\u0644 \u0627\u0644\u0648\u064a\u0628.", + "embargoed": "2024-06-06T07:00:00+00:00", + "file": [ + { + "url": "https://テスト.テスト/テスト1.pdf", + "checksum": "42fffa6c0e84ebab15f0f1ead8319efc448d0d883a0b492528997b4f17989755", + "description": "First テスト attachment", + "language": "en" + }, + { + "url": "https://テスト.テスト/テスト2.pdf", + "checksum": "0b071d7d2a430d5f4ecbd919995a8a6e18a248bccbad4dc910c9dd1e906a2e79", + "description": "Second テスト attachment", + "language": "fr" + } + ], + "image": { + "url": "https://テスト.テスト/テスト1.png", + "checksum": "c24863f6aa2065dd5071a831ee78439260ea72983799dd3e88fbfb4aa08fdc18", + "description": "Test テスト image", + "copyright": "Test テスト NGO" + }, + "disaster": [51754], + "disaster_type": [4628], + "theme": [4587, 4595, 4603], + "origin": "https://テスト.テスト/テスト/report" +} diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php index d704d3140..75a8beabd 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php @@ -299,6 +299,19 @@ public function testValidateSchema(): void { $this->assertTrue(TRUE); } + /** + * @covers ::validateSchema + */ + public function testValidateSchemaUnicode(): void { + $bundle = $this->plugin->getBundle(); + $file = __DIR__ . '/../../../data/data-' . $bundle . '-unicode.json'; + $data = json_decode(file_get_contents($file), TRUE); + + // Valid data. + $this->plugin->validateSchema($data); + $this->assertTrue(TRUE); + } + /** * @covers ::validateSchema */ From 8b896ad2c3433e4848a05e10601e71b7409b98ab Mon Sep 17 00:00:00 2001 From: orakili Date: Tue, 9 Apr 2024 05:37:46 +0000 Subject: [PATCH 20/73] feat: add notify field to post api schema to allow publication notification on a per document basis Refs: RW-831 --- .../Plugin/reliefweb_post_api/ContentProcessor/Report.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php index c96a6d2d7..46c6fca19 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php @@ -92,7 +92,10 @@ public function process(array $data): ?ContentEntityInterface { $this->setTermField($node, 'field_disaster_type', 'disaster_type', $data['disaster_type'] ?? []); $this->setTermField($node, 'field_theme', 'theme', $data['theme'] ?? []); $this->setDateField($node, 'field_embargo_date', $data['embargoed'] ?? '', FALSE); - $this->setField($node, 'field_notify', $provider->getEmailsToNotify() ?: NULL); + + // Emails to notify when the document is published. + $emails = implode(',', $data['notify'] ?? $provider->getEmailsToNotify() ?? []); + $this->setField($node, 'field_notify', $emails ?: NULL); // Add the optional files (attachments and image). $this->setReliefWebFileField($node, 'field_file', $data['file'] ?? []); From d0678c427f1674c7cd89cc4e254cf32dfd8f4a8a Mon Sep 17 00:00:00 2001 From: orakili Date: Thu, 25 Apr 2024 06:16:22 +0000 Subject: [PATCH 21/73] feat: add new API option as origin of reports Refs: RW-831 --- config/field.storage.node.field_origin.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/field.storage.node.field_origin.yml b/config/field.storage.node.field_origin.yml index 5fbbbd0d1..c8b0510ab 100644 --- a/config/field.storage.node.field_origin.yml +++ b/config/field.storage.node.field_origin.yml @@ -20,6 +20,9 @@ settings: - value: 2 label: 'Reliefweb product' + - + value: 3 + label: 'API' allowed_values_function: '' module: options locked: false From d3e0c571343417abd46b04d91c8bd82258a96ce5 Mon Sep 17 00:00:00 2001 From: orakili Date: Thu, 25 Apr 2024 06:17:28 +0000 Subject: [PATCH 22/73] feat: set the origin of reports submitted via the API to API Refs: RW-831 --- .../src/Plugin/reliefweb_post_api/ContentProcessor/Report.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php index 46c6fca19..0f522a856 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php @@ -85,8 +85,10 @@ public function process(array $data): ?ContentEntityInterface { $this->setTermField($node, 'field_country', 'country', $data['country']); $this->setField($node, 'field_primary_country', $node->field_country?->first()?->getValue()); + // Set the origin to "API". + $this->setField($node, 'field_origin', 3); + // Set the optional fields. - $this->setField($node, 'field_origin', 0); $this->setUrlField($node, 'field_origin_notes', $data['origin'] ?? '', $provider->getUrlPattern()); $this->setTermField($node, 'field_disaster', 'disaster', $data['disaster'] ?? []); $this->setTermField($node, 'field_disaster_type', 'disaster_type', $data['disaster_type'] ?? []); From 85266eedfbf2e85a07681308450c8f26421f2df4 Mon Sep 17 00:00:00 2001 From: orakili Date: Thu, 25 Apr 2024 06:18:16 +0000 Subject: [PATCH 23/73] feat: only show API as possible origin for report submitted via the API and hide this option for other reports Refs: RW-831 --- .../src/Services/ReportFormAlter.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/html/modules/custom/reliefweb_entities/src/Services/ReportFormAlter.php b/html/modules/custom/reliefweb_entities/src/Services/ReportFormAlter.php index be44f632c..f71991234 100644 --- a/html/modules/custom/reliefweb_entities/src/Services/ReportFormAlter.php +++ b/html/modules/custom/reliefweb_entities/src/Services/ReportFormAlter.php @@ -94,6 +94,16 @@ protected function addBundleFormAlterations(array &$form, FormStateInterface $fo ); } + $entity = $form_state->getFormObject()?->getEntity(); + // Only keep the "API" origin if the document was submitted via the API. + if (isset($entity) && $entity->hasField('field_post_api_provider') && !empty($entity->field_post_api_provider?->target_id)) { + FormHelper::removeOptions($form, 'field_origin', [0, 1, 2]); + } + // Otherwise hide it. + else { + FormHelper::removeOptions($form, 'field_origin', [3]); + } + // Validate the attachments. $form['#validate'][] = [$this, 'validateAttachment']; From ca0e6544014d77db3c9ea48b0d1345ce94f6f874 Mon Sep 17 00:00:00 2001 From: orakili Date: Thu, 25 Apr 2024 06:20:52 +0000 Subject: [PATCH 24/73] feat: add a column with the origin and a filter for the origin in the report moderation backend Refs: RW-831 --- .../src/ModerationServiceBase.php | 16 ++++++++++++++++ .../src/Services/ReportModeration.php | 8 ++++++++ .../reliefweb-moderation-table.html.twig | 2 ++ .../components/rw-moderation/rw-moderation.css | 4 ++++ 4 files changed, 30 insertions(+) diff --git a/html/modules/custom/reliefweb_moderation/src/ModerationServiceBase.php b/html/modules/custom/reliefweb_moderation/src/ModerationServiceBase.php index e5bd7ed81..f85c99409 100644 --- a/html/modules/custom/reliefweb_moderation/src/ModerationServiceBase.php +++ b/html/modules/custom/reliefweb_moderation/src/ModerationServiceBase.php @@ -1022,6 +1022,22 @@ protected function initFilterDefinitions(array $filters = []) { 'form' => 'omnibox', 'widget' => 'search', ], + 'origin' => [ + 'type' => 'field', + 'label' => $this->t('Origin'), + 'field' => 'field_origin', + 'column' => 'value', + 'shortcut' => 'ori', + 'form' => 'omnibox', + 'widget' => 'autocomplete', + 'operator' => 'OR', + 'values' => [ + 0 => 'URL', + 1 => 'Submit mailbox', + 2 => 'ReliefWeb product', + 3 => 'API', + ], + ], 'headline' => [ 'type' => 'field', 'label' => $this->t('Headline'), diff --git a/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php b/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php index 71b95b2e5..bcc26b87b 100644 --- a/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php +++ b/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php @@ -47,6 +47,9 @@ public function getHeaders() { 'data' => [ 'label' => $this->t('Report'), ], + 'origin' => [ + 'label' => $this->t('Origin'), + ], 'date' => [ 'label' => $this->t('Posted'), 'type' => 'property', @@ -181,6 +184,10 @@ public function getRows(array $results) { // Filter out empty data. $cells['data'] = array_filter($data); + // Retrieve the origin of the document. + $options = $entity->field_origin->first()->getPossibleOptions(); + $cells['origin'] = $options[$entity->field_origin->value] ?? $this->t('N/A'); + // Date cell. $cells['date'] = [ 'date' => $this->getEntityCreationDate($entity), @@ -335,6 +342,7 @@ protected function initFilterDefinitions(array $filters = []) { 'headline', 'bury', 'key_content', + 'origin', ]); // Values are hardcoded to avoid the use of a query. diff --git a/html/modules/custom/reliefweb_moderation/templates/reliefweb-moderation-table.html.twig b/html/modules/custom/reliefweb_moderation/templates/reliefweb-moderation-table.html.twig index 0226c4388..80b4eb5f7 100644 --- a/html/modules/custom/reliefweb_moderation/templates/reliefweb-moderation-table.html.twig +++ b/html/modules/custom/reliefweb_moderation/templates/reliefweb-moderation-table.html.twig @@ -40,6 +40,8 @@ {% for id, cell in row %} {% if id == 'edit' %} diff --git a/html/themes/custom/common_design_subtheme/components/rw-moderation/rw-moderation.css b/html/themes/custom/common_design_subtheme/components/rw-moderation/rw-moderation.css index da4f10add..66b1eb9b2 100644 --- a/html/themes/custom/common_design_subtheme/components/rw-moderation/rw-moderation.css +++ b/html/themes/custom/common_design_subtheme/components/rw-moderation/rw-moderation.css @@ -185,6 +185,10 @@ .rw-moderation-list .rw-moderation-table .rw-moderation-table__cell__details__cost em { font-weight: normal; } +.rw-moderation-list .rw-moderation-table__cell--origin { + text-align: center; + font-size: 15px; +} .rw-moderation-list .rw-moderation-table__cell__date { text-align: center; white-space: pre; From 4f8bc427b5bfde3baeb620e3aa7f7c8776cf240d Mon Sep 17 00:00:00 2001 From: orakili Date: Thu, 25 Apr 2024 06:22:12 +0000 Subject: [PATCH 25/73] feat: only show the buttons for the extra statuses related to API submissions on reports submitted via the API Refs: RW-831 --- .../src/Services/ReportModeration.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php b/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php index bcc26b87b..402e28cc5 100644 --- a/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php +++ b/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php @@ -247,13 +247,17 @@ public function getEntityFormSubmitButtons($status, EntityModeratedInterface $en 'reference' => [ '#value' => $this->t('Reference'), ], - 'pending' => [ + ]; + + // Add the extra buttons to manage content submitted via the API. + if ($entity->hasField('field_post_api_provider') && !empty($entity->field_post_api_provider?->target_id)) { + $buttons['pending'] = [ '#value' => $this->t('Pending'), - ], - 'refused' => [ + ]; + $buttons['refused'] = [ '#value' => $this->t('Refused'), - ], - ]; + ]; + } // @todo replace with permission. if (UserHelper::userHasRoles(['administrator', 'webmaster'])) { From ed845211c142543de0c2903102131540415de963 Mon Sep 17 00:00:00 2001 From: orakili Date: Thu, 25 Apr 2024 06:23:06 +0000 Subject: [PATCH 26/73] feat: add the reviewer information in the report moderation backend Refs: RW-831 --- .../src/ModerationServiceBase.php | 28 +++++++++++++++++++ .../src/Services/ReportModeration.php | 11 ++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/html/modules/custom/reliefweb_moderation/src/ModerationServiceBase.php b/html/modules/custom/reliefweb_moderation/src/ModerationServiceBase.php index f85c99409..993e2cb36 100644 --- a/html/modules/custom/reliefweb_moderation/src/ModerationServiceBase.php +++ b/html/modules/custom/reliefweb_moderation/src/ModerationServiceBase.php @@ -2565,6 +2565,34 @@ protected function getEntityAuthorData(EntityModeratedInterface $entity) { return $this->getFilterLink($author_title, $author_parameter, $author_value); } + /** + * Get the entity reviewer. + * + * @param \Drupal\reliefweb_moderation\EntityModeratedInterface $entity + * Entity. + * + * @return \Drupal\Core\GeneratedLink|null + * Link to the page filtered by the user or NULL if the reviewer couldn't be + * determined. + */ + protected function getEntityReviewerData(EntityModeratedInterface $entity) { + $reviewer = $entity->getRevisionUser(); + if (!isset($reviewer)) { + return NULL; + } + + $reviewer_label = $reviewer->label(); + if (empty($reviewer_label)) { + return NULL; + } + + $reviewer_email = $reviewer->getEmail() ?? $this->t('email missing'); + + $parameter = 'selection[reviewer][]'; + $value = $reviewer->id() . ':' . $reviewer_label . ' (' . $reviewer_email . ')'; + return $this->getFilterLink($reviewer_label, $parameter, $value); + } + /** * Get the revision information for the entity. * diff --git a/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php b/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php index 402e28cc5..a7a46e9b4 100644 --- a/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php +++ b/html/modules/custom/reliefweb_moderation/src/Services/ReportModeration.php @@ -174,8 +174,15 @@ public function getRows(array $results) { '@name' => Link::fromTextAndUrl($key_content_data['name'], Url::fromUri('entity:taxonomy_term/' . $key_content_data['tid']))->toString(), ]); } - // Author. - $details['author'] = $this->getEntityAuthorData($entity); + + // Author and reviewer. + $details['author'] = $this->t('author: @author', [ + '@author' => $this->getEntityAuthorData($entity), + ]); + $details['reviewer'] = $this->t('reviewer: @reviewer', [ + '@reviewer' => $this->getEntityReviewerData($entity), + ]); + $data['details'] = array_filter($details); // Revision information. From 5324bb1e66cf8f8df753d62f7957ac8e91d7c830 Mon Sep 17 00:00:00 2001 From: orakili Date: Fri, 26 Apr 2024 04:48:38 +0000 Subject: [PATCH 27/73] feat: skip processing of refused entities Refs: RW-831 --- .../src/Controller/ReliefWebPostApi.php | 6 +++ .../src/Plugin/ContentProcessorPluginBase.php | 19 +++++++++ .../ContentProcessorPluginInterface.php | 14 +++++++ .../ContentProcessor/Report.php | 8 ++++ .../Controller/ReliefWebPostApiTest.php | 29 ++++++++++++++ .../Plugin/ContentProcessorPluginBaseTest.php | 39 +++++++++++++++++++ .../ContentProcessor/ReportTest.php | 34 ++++++++++++++++ 7 files changed, 149 insertions(+) diff --git a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php index b085e1d11..bc5edc3f1 100644 --- a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php +++ b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php @@ -20,6 +20,7 @@ use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Uid\Uuid; /** @@ -133,6 +134,11 @@ public function postContent(string $resource, string $uuid): JsonResponse { throw new BadRequestHttpException('Invalid content format.'); } + // Check if the submission can be processed. + if (!$plugin->isProcessable($uuid)) { + throw new UnprocessableEntityHttpException('Unprocessable submission.'); + } + // Retrieve and decode the body. $body = $request->getContent(); if (empty($body)) { diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php index 087b5db97..49c1a6d6d 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php @@ -238,6 +238,25 @@ public function getProvider(string $uuid): ProviderInterface { */ abstract public function process(array $data): ?ContentEntityInterface; + /** + * {@inheritdoc} + */ + public function isProcessable(string $uuid): bool { + $storage = $this->entityTypeManager->getStorage($this->getEntityType()); + $uuid_key = $storage->getEntityType()->getKey('uuid'); + + // Check if the entity is marked as refused, in which case it cannot be + // processed. + $ids = $storage + ->getQuery() + ->accessCheck(FALSE) + ->condition($uuid_key, $uuid, '=') + ->condition('moderation_status', 'refused', '=') + ->execute(); + + return empty($ids); + } + /** * {@inheritdoc} */ diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginInterface.php b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginInterface.php index 3581c8be7..54c10cd9f 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginInterface.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginInterface.php @@ -98,6 +98,20 @@ public function getProvider(string $id): ProviderInterface; */ public function process(array $data): ?ContentEntityInterface; + /** + * Checks of the entity with the give UUID can be processed/submitted. + * + * By default, entities marked as refused by the editorial team are not + * processed again and their submission are not queued anymore. + * + * @param string $uuid + * Entity UUID. + * + * @return bool + * TRUE if the submission can be processed. + */ + public function isProcessable(string $uuid): bool; + /** * Validate POST API data. * diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php index 0f522a856..96cac8825 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Report.php @@ -73,6 +73,14 @@ public function process(array $data): ?ContentEntityInterface { ])); } + // Skip if the node was marked as refused. + if (!$node->isNew() && $node->getModerationStatus() === 'refused') { + throw new ContentProcessorException(strtr('Skipping processing: existing entity with the UUID @uuid is marked as refused.', [ + '@uuid' => $uuid, + '@bundle' => $bundle, + ])); + } + // Set the mandatory fields. $node->title = $this->sanitizeString($data['title']); diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php index e39afe135..67615df7e 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Controller/ReliefWebPostApiTest.php @@ -14,6 +14,8 @@ use Drupal\Core\Queue\QueueInterface; use Drupal\reliefweb_post_api\Controller\ReliefWebPostApi; use Drupal\reliefweb_post_api\Entity\ProviderInterface; +use Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginInterface; +use Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginManagerInterface; use Symfony\Component\HttpFoundation\HeaderBag; use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\Request; @@ -321,6 +323,33 @@ public function testCheckRateLimits(): void { $this->assertTrue(TRUE); } + /** + * @covers ::postContent + */ + public function testPostContentUnprocessable(): void { + $plugin = $this->createConfiguredMock(ContentProcessorPluginInterface::class, [ + 'getProvider' => $this->getTestProvider(), + 'isProcessable' => FALSE, + ]); + + $plugin_manager = $this->createConfiguredMock(ContentProcessorPluginManagerInterface::class, [ + 'getPluginByResource' => $plugin, + ]); + + $request = $this->createMockRequest(); + + $request_stack = $this->createMockRequestStack($request); + + $controller = $this->createTestController(services: [ + 'request_stack' => $request_stack, + 'plugin.manager.reliefweb_post_api.content_processor' => $plugin_manager, + ]); + + $response = $controller->postContent('reports', $this->getTestUuid()); + $this->assertSame(422, $response->getStatusCode()); + $this->assertStringContainsString('Unprocessable submission.', $response->getContent()); + } + /** * @covers ::postContent */ diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php index 75a8beabd..2481b12e5 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/ContentProcessorPluginBaseTest.php @@ -10,7 +10,9 @@ use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\Query\QueryInterface; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\file\Entity\File; @@ -243,6 +245,43 @@ public function testGetProviderUnknown(): void { $plugin->getProvider($uuid); } + /** + * @covers ::isProcessable + */ + public function testIsProcessable(): void { + $uuid1 = 'a07b9b6c-0374-11ef-90f5-325096b39f47'; + $uuid2 = 'b5724a52-0374-11ef-9d42-325096b39f47'; + + $entity_type = $this->createConfiguredMock(EntityTypeInterface::class, [ + 'getKey' => 'uuid', + ]); + + $query = $this->createConfiguredMock(QueryInterface::class, [ + 'accessCheck' => $this->returnSelf(), + 'condition' => $this->returnSelf(), + ]); + $query->method('execute')->willReturnOnConsecutiveCalls(['12345'], []); + + $storage = $this->createConfiguredMock(EntityStorageInterface::class, [ + 'getEntityType' => $entity_type, + 'getQuery' => $query, + ]); + + $entity_type_manager = $this->createConfiguredMock(EntityTypeManagerInterface::class, [ + 'getStorage' => $storage, + ]); + + $plugin = $this->createDummyPlugin(services: [ + 'entity_type.manager' => $entity_type_manager, + ]); + + $result = $plugin->isProcessable($uuid1); + $this->assertFalse($result); + + $result = $plugin->isProcessable($uuid2); + $this->assertTrue($result); + } + /** * @covers ::validate */ diff --git a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php index 4e671347a..2cb741c0e 100644 --- a/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php +++ b/html/modules/custom/reliefweb_post_api/tests/src/ExistingSite/Plugin/reliefweb_post_api/ContentProcessor/ReportTest.php @@ -123,6 +123,40 @@ public function testProcessWrongBundle(): void { $plugin->process($data); } + /** + * @covers ::process + */ + public function testProcessRefused(): void { + $entity_repository = $this->createMock(EntityRepositoryInterface::class); + + // Create a new instance of the current plugin with some mocked services. + $plugin = $this->createDummyPlugin($this->plugin->getPluginDefinition(), [ + 'entity.repository' => $entity_repository, + ]); + + $data = ['source' => [123]] + $this->getPostApiData(); + + $provider = $this->getTestProvider(); + + $entity = $this->createEntity('node', 'report'); + $entity->nid = 123; + $entity->uuid = $plugin->generateUuid($data['url']); + $entity->moderation_status = 'refused'; + $entity->enforceIsNew(FALSE); + + $entity_repository->expects($this->any()) + ->method('loadEntityByUuid') + ->willReturnMap([ + ['node', $entity->uuid(), $entity], + ['reliefweb_post_api_provider', $provider->uuid(), $provider], + ]); + + $this->expectException(ContentProcessorException::class); + $this->expectExceptionMessage('is marked as refused'); + + $plugin->process($data); + } + /** * @covers ::validateUrls */ From 18bebe071b465173d30153b41c96dfc700caa984 Mon Sep 17 00:00:00 2001 From: orakili Date: Thu, 2 May 2024 02:36:26 +0000 Subject: [PATCH 28/73] chore: latest coding standards Refs: RW-831 --- .../reliefweb-guidelines/reliefweb-guidelines.css | 2 -- .../src/Attribute/ContentProcessor.php | 2 +- .../src/Commands/ReliefWebPostApiCommands.php | 12 +++++++----- .../src/Controller/ReliefWebPostApi.php | 4 ++-- .../reliefweb_post_api/src/Form/ProviderForm.php | 4 ++-- .../src/Plugin/ContentProcessorPluginBase.php | 4 ++-- .../src/Plugin/ContentProcessorPluginManager.php | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/html/modules/custom/reliefweb_guidelines/components/reliefweb-guidelines/reliefweb-guidelines.css b/html/modules/custom/reliefweb_guidelines/components/reliefweb-guidelines/reliefweb-guidelines.css index ae28488d2..fee4aac52 100644 --- a/html/modules/custom/reliefweb_guidelines/components/reliefweb-guidelines/reliefweb-guidelines.css +++ b/html/modules/custom/reliefweb_guidelines/components/reliefweb-guidelines/reliefweb-guidelines.css @@ -398,10 +398,8 @@ aside nav a.active + a { position: relative; margin-top: 15px; margin-top: 1em; - margin-bottom: 15px; margin-bottom: 16px; font-weight: bold; - line-height: 1.1; line-height: 1.4; } diff --git a/html/modules/custom/reliefweb_post_api/src/Attribute/ContentProcessor.php b/html/modules/custom/reliefweb_post_api/src/Attribute/ContentProcessor.php index 95eb81611..97c05d348 100644 --- a/html/modules/custom/reliefweb_post_api/src/Attribute/ContentProcessor.php +++ b/html/modules/custom/reliefweb_post_api/src/Attribute/ContentProcessor.php @@ -36,7 +36,7 @@ public function __construct( public readonly TranslatableMarkup $label, public readonly string $entityType, public readonly string $bundle, - public readonly string $resource + public readonly string $resource, ) {} } diff --git a/html/modules/custom/reliefweb_post_api/src/Commands/ReliefWebPostApiCommands.php b/html/modules/custom/reliefweb_post_api/src/Commands/ReliefWebPostApiCommands.php index da87a3278..7ccd08b04 100644 --- a/html/modules/custom/reliefweb_post_api/src/Commands/ReliefWebPostApiCommands.php +++ b/html/modules/custom/reliefweb_post_api/src/Commands/ReliefWebPostApiCommands.php @@ -18,7 +18,7 @@ class ReliefWebPostApiCommands extends DrushCommands { */ public function __construct( protected QueueFactory $queueFactory, - protected ContentProcessorPluginManagerInterface $contentProcessorPluginManager + protected ContentProcessorPluginManagerInterface $contentProcessorPluginManager, ) {} /** @@ -41,10 +41,12 @@ public function __construct( * * @validate-module-enabled reliefweb_post_api */ - public function process(array $options = [ - 'limit' => 10, - 'bundles' => '', - ]): void { + public function process( + array $options = [ + 'limit' => 10, + 'bundles' => '', + ], + ): void { $queue = $this->queueFactory->get('reliefweb_post_api'); $count = 0; diff --git a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php index bc5edc3f1..210a004a3 100644 --- a/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php +++ b/html/modules/custom/reliefweb_post_api/src/Controller/ReliefWebPostApi.php @@ -50,7 +50,7 @@ public function __construct( protected ExtensionPathResolver $pathResolver, protected Connection $database, protected TimeInterface $time, - protected ContentProcessorPluginManagerInterface $contentProcessorPluginManager + protected ContentProcessorPluginManagerInterface $contentProcessorPluginManager, ) {} /** @@ -63,7 +63,7 @@ public static function create(ContainerInterface $container) { $container->get('extension.path.resolver'), $container->get('database'), $container->get('datetime.time'), - $container->get('plugin.manager.reliefweb_post_api.content_processor') + $container->get('plugin.manager.reliefweb_post_api.content_processor'), ); } diff --git a/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php b/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php index 62710c890..53ec5c7c2 100644 --- a/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php +++ b/html/modules/custom/reliefweb_post_api/src/Form/ProviderForm.php @@ -38,7 +38,7 @@ public function __construct( EntityTypeBundleInfoInterface $entity_type_bundle_info, TimeInterface $time, protected PasswordInterface $password, - protected ContentProcessorPluginManagerInterface $contentProcessorPluginManager + protected ContentProcessorPluginManagerInterface $contentProcessorPluginManager, ) { parent::__construct($entity_repository, $entity_type_bundle_info, $time); } @@ -52,7 +52,7 @@ public static function create(ContainerInterface $container) { $container->get('entity_type.bundle.info'), $container->get('datetime.time'), $container->get('password'), - $container->get('plugin.manager.reliefweb_post_api.content_processor') + $container->get('plugin.manager.reliefweb_post_api.content_processor'), ); } diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php index 49c1a6d6d..6edda1b91 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginBase.php @@ -112,7 +112,7 @@ public function __construct( protected FileSystemInterface $fileSystem, protected FileValidatorInterface $fileValidator, protected MimeTypeGuesserInterface $mimeTypeGuesser, - protected LanguageManagerInterface $languageManager + protected LanguageManagerInterface $languageManager, ) { parent::__construct( $configuration, @@ -138,7 +138,7 @@ public static function create(ContainerInterface $container, array $configuratio $container->get('file_system'), $container->get('file.validator'), $container->get('file.mime_type.guesser'), - $container->get('language_manager') + $container->get('language_manager'), ); } diff --git a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManager.php b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManager.php index 5794be7b1..164678970 100644 --- a/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManager.php +++ b/html/modules/custom/reliefweb_post_api/src/Plugin/ContentProcessorPluginManager.php @@ -39,14 +39,14 @@ class ContentProcessorPluginManager extends DefaultPluginManager implements Cont public function __construct( \Traversable $namespaces, CacheBackendInterface $cache_backend, - ModuleHandlerInterface $module_handler + ModuleHandlerInterface $module_handler, ) { parent::__construct( 'Plugin/reliefweb_post_api/ContentProcessor', $namespaces, $module_handler, 'Drupal\reliefweb_post_api\Plugin\ContentProcessorPluginInterface', - ContentProcessor::class + ContentProcessor::class, ); $this->alterInfo('reliefweb_post_api_content_processor_info'); $this->setCacheBackend($cache_backend, 'reliefweb_post_api_content_processors'); From a30347acbd7f38ea65b22cb642d3b1d29f7cf7bd Mon Sep 17 00:00:00 2001 From: "Peter Droogmans (attiks)" Date: Fri, 10 May 2024 13:35:02 +0200 Subject: [PATCH 29/73] feat: Post API for jobs Refs: #RW-967 --- .../reliefweb_post_api/schemas/v2/job.json | 176 ++++++++++++++++++ .../ContentProcessor/Job.php | 121 ++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 html/modules/custom/reliefweb_post_api/schemas/v2/job.json create mode 100644 html/modules/custom/reliefweb_post_api/src/Plugin/reliefweb_post_api/ContentProcessor/Job.php diff --git a/html/modules/custom/reliefweb_post_api/schemas/v2/job.json b/html/modules/custom/reliefweb_post_api/schemas/v2/job.json new file mode 100644 index 000000000..ab3eedf84 --- /dev/null +++ b/html/modules/custom/reliefweb_post_api/schemas/v2/job.json @@ -0,0 +1,176 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://reliefweb.int/post-api-schemas/v2/job.json", + "title": "ReliefWeb POST API schema - job resource", + "type": "object", + "properties": { + "url": { + "description": "Unique URL to identify the document. Use the original canonical of the document if available.", + "type": "string", + "format": "iri", + "maxLength": 2048 + }, + "uuid": { + "description": "The universally unique identifier (UUID) version 5 generated from the URL property above, with the namespace: '8e27a998-c362-5d1f-b152-d474e1d36af2'.", + "type": "string", + "format": "uuid" + }, + "title": { + "description": "Job title.", + "type": "string", + "minLength": 10, + "maxLength": 255, + "allOf": [ + { + "description": "Must contain letters (any language).", + "pattern": "\\p{L}+" + }, + { + "description": "No control characters or separators except for spaces.", + "pattern": "^([^\\p{Z}\\p{C}]|[ \u3000])+$" + } + ], + "not": { + "description": "No leading, trailing or consecutive spaces.", + "pattern": "(?:^[ \u3000]|[ \u3000]$|[ \u3000]{2,})" + } + }, + "career_categories": { + "description": "Job career categoriy as a list of IDs from https://api.reliefweb.int/v1/career_categories.", + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 0, + "maxItems": 1 + }, + "city": { + "description": "Job title.", + "type": "string", + "minLength": 0, + "maxLength": 255, + "allOf": [ + { + "description": "Must contain letters (any language).", + "pattern": "\\p{L}+" + }, + { + "description": "No control characters or separators except for spaces.", + "pattern": "^([^\\p{Z}\\p{C}]|[ \u3000])+$" + } + ], + "not": { + "description": "No leading, trailing or consecutive spaces.", + "pattern": "(?:^[ \u3000]|[ \u3000]$|[ \u3000]{2,})" + } + }, + "closing_date": { + "description": "Original publication date (ISO 8601) of the document.", + "type": "string", + "format": "date-time" + }, + "country": { + "description": "Document country(ies) as a list of IDs from https://api.reliefweb.int/v1/countries. The first country in the list is considered the primary country, meaning the most relevant to the content of the document.", + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 300 + }, + "how_to_apply": { + "description": "Document content in markdown or html (supported tags: