Skip to content

Commit

Permalink
CXP-1300: Adds first product meta schema (#17982)
Browse files Browse the repository at this point in the history
* CXP-1300: Adds first product meta schema

* CXP-1300: remove  &  from schema

* CXP-1300: fix tests
  • Loading branch information
tseho authored and Beninho committed Sep 29, 2022
1 parent a3d5550 commit 19335d1
Show file tree
Hide file tree
Showing 8 changed files with 479 additions and 11 deletions.
22 changes: 15 additions & 7 deletions components/catalogs/back/.php_cd.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
'Akeneo\Catalogs\Application',
'Akeneo\Catalogs\Infrastructure',

// Allowed dependencies in Infrastructure
'Symfony\Component\Config',
'Symfony\Component\Console',
'Symfony\Component\DependencyInjection',
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Akeneo\Catalogs\Infrastructure\Validation;

use Symfony\Component\Validator\Constraint;

/**
* @copyright 2022 Akeneo SAS (http://www.akeneo.com)
* @license http://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0)
*
* @psalm-suppress PropertyNotSetInConstructor
*/
final class ProductSchema extends Constraint
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace Akeneo\Catalogs\Infrastructure\Validation;

use Opis\JsonSchema\Errors\ErrorFormatter;
use Opis\JsonSchema\Validator;
use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

/**
* @copyright 2022 Akeneo SAS (http://www.akeneo.com)
* @license http://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0)
*
* @psalm-suppress PropertyNotSetInConstructor
*/
final class ProductSchemaValidator extends ConstraintValidator
{
public function __construct(private LoggerInterface $logger)
{
}

public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof ProductSchema) {
throw new UnexpectedTypeException($constraint, ProductSchema::class);
}

if (!\is_object($value)) {
throw new UnexpectedValueException($value, 'object');
}

$metaSchemaId = $value->{'$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,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

namespace Akeneo\Catalogs\Test\Integration\Infrastructure\Validation;

use Akeneo\Catalogs\Infrastructure\Validation\ProductSchema;
use Akeneo\Catalogs\Test\Integration\IntegrationTestCase;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
* @copyright 2022 Akeneo SAS (http://www.akeneo.com)
* @license http://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0)
*
* @covers \Akeneo\Catalogs\Infrastructure\Validation\ProductSchema
* @covers \Akeneo\Catalogs\Infrastructure\Validation\ProductSchemaValidator
*/
class ProductSchemaValidatorTest extends IntegrationTestCase
{
private ?ValidatorInterface $validator;

protected function setUp(): void
{
parent::setUp();

$this->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,
],
];
}
}
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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",
Expand Down Expand Up @@ -207,7 +208,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": {
Expand Down
Loading

0 comments on commit 19335d1

Please sign in to comment.