diff --git a/components/catalogs/back/.php_cd.php b/components/catalogs/back/.php_cd.php index 1c57cf7ea856..3c73e1840aec 100644 --- a/components/catalogs/back/.php_cd.php +++ b/components/catalogs/back/.php_cd.php @@ -37,6 +37,7 @@ 'Akeneo\Catalogs\Application', 'Akeneo\Catalogs\Infrastructure', + // Allowed dependencies in Infrastructure 'Symfony\Component\Config', 'Symfony\Component\Console', 'Symfony\Component\DependencyInjection', @@ -50,25 +51,32 @@ 'Symfony\Component\Validator', 'Doctrine\DBAL', 'Ramsey\Uuid\Uuid', + 'League\Flysystem\Filesystem', + 'Opis\JsonSchema', + 'Psr\Log\LoggerInterface', 'Akeneo\Platform\Bundle\InstallerBundle', 'Akeneo\Platform\Bundle\FrameworkBundle\Security\SecurityFacadeInterface', 'Akeneo\Tool\Component\Api', - 'Akeneo\UserManagement\Component\Model\UserInterface', - 'Akeneo\UserManagement\Component\Repository\UserRepositoryInterface', 'Akeneo\Connectivity\Connection\ServiceApi', - 'League\Flysystem\Filesystem', + 'Akeneo\Tool\Bundle\MeasureBundle\ServiceApi', + + /**********************************************************************************************************/ + /* Below are dependencies that we have, but we shouldn't rely on them. + /* They are coupling exceptions that should be replaced by better alternatives, like ServiceAPIs. + /**********************************************************************************************************/ + + // This class is not clearly identified as public API 'Akeneo\Connectivity\Connection\Infrastructure\Apps\Security\ScopeMapperInterface', - // used in Persistence\Measurement - 'Akeneo\Tool\Bundle\MeasureBundle\ServiceApi\FindMeasurementFamilies', + // used in GetCurrentUsernameTrait + 'Akeneo\UserManagement\Component\Model\UserInterface', + 'Akeneo\UserManagement\Component\Repository\UserRepositoryInterface', // used in TemporaryEnrichmentBridge 'Akeneo\Tool\Bundle\ElasticsearchBundle\Client', 'Akeneo\Tool\Component\StorageUtils\Cursor\CursorFactoryInterface', 'Symfony\Component\OptionsResolver', - // @todo replace next ones with the ones from service API when available - // used in Persistence\Attribute 'Akeneo\Pim\Structure\Component\Model\AttributeInterface', 'Akeneo\Pim\Structure\Component\Repository\AttributeRepositoryInterface', diff --git a/components/catalogs/back/src/Infrastructure/Symfony/Resources/meta-schemas/product-0.0.1.json b/components/catalogs/back/src/Infrastructure/Symfony/Resources/meta-schemas/product-0.0.1.json new file mode 100644 index 000000000000..80f02d076b8d --- /dev/null +++ b/components/catalogs/back/src/Infrastructure/Symfony/Resources/meta-schemas/product-0.0.1.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://api.akeneo.com/mapping/product/0.0.1/schema", + "type": "object", + "properties": { + "$id": { "$ref": "#/$defs/$id" }, + "$schema": { "$ref": "#/$defs/$schema" }, + "$comment": { "$ref": "#/$defs/$comment" }, + "$defs": { "$ref": "#/$defs/$defs" }, + "title": { "$ref": "#/$defs/title" }, + "description": { "$ref": "#/$defs/description" }, + "type": { "const": "object" }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/property" }, + "default": {} + } + }, + "$defs": { + "$id": { + "$comment": "Non-empty fragments not allowed.", + "pattern": "^[^#]*#?$" + }, + "$schema": { "$ref": "#/$defs/uriString" }, + "$comment": { + "type": "string" + }, + "$defs": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/property" } + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "propertyType": { + "enum": [ + "string" + ] + }, + "property": { + "type": "object", + "properties": { + "title": { "$ref": "#/$defs/title" }, + "description": { "$ref": "#/$defs/description" }, + "type": { "$ref": "#/$defs/propertyType" } + }, + "additionalProperties": false, + "required": ["type"] + }, + "uriString": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": false +} diff --git a/components/catalogs/back/src/Infrastructure/Validation/ProductSchema.php b/components/catalogs/back/src/Infrastructure/Validation/ProductSchema.php new file mode 100644 index 000000000000..2492b280efe2 --- /dev/null +++ b/components/catalogs/back/src/Infrastructure/Validation/ProductSchema.php @@ -0,0 +1,17 @@ +{'$schema'} ?? null; + if (null === $metaSchemaId || !\is_string($metaSchemaId)) { + $this->context + ->buildViolation('You must provide a $schema reference.') + ->addViolation(); + + return; + } + + $metaSchemaPath = $this->getMetaSchemaLocalPath($metaSchemaId); + if (null === $metaSchemaPath) { + $this->context + ->buildViolation('You must provide a valid $schema reference.') + ->addViolation(); + + return; + } + + $validator = new Validator(); + $resolver = $validator->resolver(); + \assert(null !== $resolver); + $resolver->registerFile($metaSchemaId, $metaSchemaPath); + + $result = $validator->validate($value, $metaSchemaId); + + if ($result->hasError()) { + $this->context + ->buildViolation('You must provide a valid schema.') + ->addViolation(); + + $formatter = new ErrorFormatter(); + $this->logger->debug( + 'A Product Mapping Schema validation failed', + $formatter->formatOutput($result->error(), 'verbose') + ); + } + } + + private function getMetaSchemaLocalPath(string $id): ?string + { + return match ($id) { + 'https://api.akeneo.com/mapping/product/0.0.1/schema' => __DIR__.'/../Symfony/Resources/meta-schemas/product-0.0.1.json', + default => null, + }; + } +} diff --git a/components/catalogs/back/tests/Integration/Infrastructure/Validation/ProductSchemaValidatorTest.php b/components/catalogs/back/tests/Integration/Infrastructure/Validation/ProductSchemaValidatorTest.php new file mode 100644 index 000000000000..014e922ca14e --- /dev/null +++ b/components/catalogs/back/tests/Integration/Infrastructure/Validation/ProductSchemaValidatorTest.php @@ -0,0 +1,110 @@ +validator = self::getContainer()->get(ValidatorInterface::class); + } + + /** + * @dataProvider validSchemaDataProvider + */ + public function testItAcceptsTheSchema(string $schema): void + { + $violations = $this->validator->validate( + \json_decode($schema, false, 512, JSON_THROW_ON_ERROR), + new ProductSchema() + ); + + $this->assertEmpty($violations); + } + + /** + * @dataProvider invalidSchemaDataProvider + */ + public function testItRejectsTheSchema(string $schema): void + { + $violations = $this->validator->validate( + \json_decode($schema, false, 512, JSON_THROW_ON_ERROR), + new ProductSchema() + ); + + $this->assertNotEmpty($violations); + } + + public function validSchemaDataProvider(): array + { + return [ + '0.0.1 with valid schema' => [ + 'schema' => <<<'JSON_WRAP' +{ + "$id": "https://example.com/product", + "$schema": "https://api.akeneo.com/mapping/product/0.0.1/schema", + "$comment": "My first schema !", + "title": "Product Mapping", + "description": "JSON Schema describing the structure of products expected by our application", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "body_html": { + "title": "Description", + "description": "Product description in raw HTML", + "type": "string" + } + } +} +JSON_WRAP, + ], + ]; + } + + public function invalidSchemaDataProvider(): array + { + return [ + '0.0.1 with invalid type number' => [ + 'schema' => <<<'JSON_WRAP' +{ + "$schema": "https://api.akeneo.com/mapping/product/0.0.1/schema", + "properties": { + "price": { + "type": "number" + } + } +} +JSON_WRAP, + ], + '0.0.1 with missing target type' => [ + 'schema' => <<<'JSON_WRAP' +{ + "$schema": "https://api.akeneo.com/mapping/product/0.0.1/schema", + "properties": { + "price": {} + } +} +JSON_WRAP, + ], + ]; + } +} diff --git a/composer.json b/composer.json index d90d87840842..ca8048a094f8 100644 --- a/composer.json +++ b/composer.json @@ -105,6 +105,7 @@ "monolog/monolog": "1.25.5", "ocramius/proxy-manager": "2.11.1", "oneup/flysystem-bundle": "^4.0.0", + "opis/json-schema": "^2.3", "phpseclib/phpseclib": "2.0.31", "psr/event-dispatcher": "^1.0.0", "ramsey/uuid": "^3.7", @@ -205,7 +206,8 @@ "sort-packages": true, "allow-plugins": { "symfony/flex": true, - "dealerdirect/phpcodesniffer-composer-installer": true + "dealerdirect/phpcodesniffer-composer-installer": true, + "composer/package-versions-deprecated": true } }, "extra": { diff --git a/composer.lock b/composer.lock index 6b62419fe422..ebdd5220f094 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": "fb9bfb75cf6fd78bf25a5501f5ceded9", + "content-hash": "0b32b6dbc05e48f738dce5c7e6a70db2", "packages": [ { "name": "akeneo/oauth-server-bundle", @@ -4594,6 +4594,196 @@ }, "time": "2022-07-13T07:10:37+00:00" }, + { + "name": "opis/json-schema", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "reference": "c48df6d7089a45f01e1c82432348f2d5976f9bfb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.0", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.3.0" + }, + "time": "2022-01-08T20:38:03+00:00" + }, + { + "name": "opis/string", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "9ebf1a1f873f502f6859d11210b25a4bf5d141e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/9ebf1a1f873f502f6859d11210b25a4bf5d141e7", + "reference": "9ebf1a1f873f502f6859d11210b25a4bf5d141e7", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.0.1" + }, + "time": "2022-01-14T15:42:23+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, { "name": "paragonie/random_compat", "version": "v9.99.100", @@ -18382,5 +18572,5 @@ "ext-zip": "*" }, "platform-dev": [], - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.2.0" } diff --git a/src/Akeneo/Channel/back/Infrastructure/Component/Repository/CurrencyRepositoryInterface.php b/src/Akeneo/Channel/back/Infrastructure/Component/Repository/CurrencyRepositoryInterface.php index be0368c902ea..e3651ccf6085 100644 --- a/src/Akeneo/Channel/back/Infrastructure/Component/Repository/CurrencyRepositoryInterface.php +++ b/src/Akeneo/Channel/back/Infrastructure/Component/Repository/CurrencyRepositoryInterface.php @@ -25,7 +25,7 @@ public function getActivatedCurrencies(); /** * Return an array of currency codes * - * @return array + * @return array */ public function getActivatedCurrencyCodes(); }