Commit 8491b423 authored by Stephen Lucero's avatar Stephen Lucero
Browse files

Issue #3295055 by slucero: Pattern Validation Sometimes Fails When Encountering Empty Objects

parent af6990f8
Loading
Loading
Loading
Loading
+24 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\patternkit\Schema\DataPreProcessor;

use Swaggest\JsonSchema\DataPreProcessor;
use Swaggest\JsonSchema\Schema;

/**
 * Preprocess data to cast arrays to objects if desired by the schema.
 */
class ObjectCoercionDataPreProcessor implements DataPreProcessor {

  /**
   * {@inheritdoc}
   */
  public function process($data, Schema $schema, $import = TRUE) {
    if (property_exists($schema, 'type') && $schema->type == 'object' && is_array($data)) {
      $data = (object) $data;
    }

    return $data;
  }

}
+77 −3
Original line number Diff line number Diff line
@@ -5,7 +5,9 @@ namespace Drupal\patternkit\Schema;
use Drupal\patternkit\Exception\SchemaException;
use Drupal\patternkit\Exception\SchemaReferenceException;
use Drupal\patternkit\Exception\SchemaValidationException;
use Drupal\patternkit\Schema\DataPreProcessor\ObjectCoercionDataPreProcessor;
use Swaggest\JsonSchema\Context;
use Swaggest\JsonSchema\DataPreProcessor;
use Swaggest\JsonSchema\Exception;
use Swaggest\JsonSchema\InvalidValue;
use Swaggest\JsonSchema\RemoteRefProvider;
@@ -28,14 +30,37 @@ class SchemaFactory {
   */
  protected RemoteRefProvider $refProvider;

  /**
   * A data preprocessor to configure on all schema instances.
   *
   * @var \Swaggest\JsonSchema\DataPreProcessor
   */
  protected DataPreProcessor $dataPreProcessor;

  /**
   * The default context to use for schema operations.
   *
   * @var \Swaggest\JsonSchema\Context
   */
  protected Context $defaultContext;

  /**
   * Create a new Schema instance.
   *
   * @param \Swaggest\JsonSchema\RemoteRefProvider $refProvider
   *   The ref provider for loading schema references.
   * @param \Swaggest\JsonSchema\DataPreProcessor|null $dataPreProcessor
   *   (Optional) A data preprocessor to use on all Schema instances. Defaults
   *   to an ObjectCoercionDataPreProcessor if none is provided.
   *
   * @see \Drupal\patternkit\Schema\DataPreProcessor\ObjectCoercionDataPreProcessor
   */
  public function __construct(RemoteRefProvider $refProvider) {
  public function __construct(RemoteRefProvider $refProvider, ?DataPreProcessor $dataPreProcessor = NULL) {
    $this->refProvider = $refProvider;
    $this->dataPreProcessor = $dataPreProcessor ?? new ObjectCoercionDataPreProcessor();

    $this->defaultContext = new Context($refProvider);
    $this->defaultContext->setDataPreProcessor($this->dataPreProcessor);
  }

  /**
@@ -43,6 +68,10 @@ class SchemaFactory {
   *
   * @param string $schema
   *   The schema JSON for injection into the schema instance.
   * @param \Swaggest\JsonSchema\Context|null $context
   *   (Optional) Context configuration for schema processing and operations.
   *   In most cases, this is not needed, but it may be provided for detailed
   *   customization of how a Schema instance should behave.
   *
   * @return \Swaggest\JsonSchema\SchemaContract
   *   A configured Schema instance.
@@ -54,9 +83,27 @@ class SchemaFactory {
   *   Throws an exception if the given value fails to validate against the
   *   provided schema.
   */
  public function createInstance(string $schema): SchemaContract {
  public function createInstance(string $schema, ?Context $context = NULL): SchemaContract {
    // Configure defaults on the provided context options if one is provided.
    if ($context !== NULL) {
      // Don't override the remote ref provider if one is already configured
      // for use.
      if ($context->getRemoteRefProvider() === NULL) {
        $context->setRemoteRefProvider($this->refProvider);
      }

      // Don't override the data preprocessor if one is already configured
      // for use.
      if ($context->getDataPreProcessor() === NULL) {
        $context->setDataPreProcessor($this->dataPreProcessor);
      }
    }
    else {
      $context = $this->getDefaultContext();
    }

    try {
      return Schema::import(json_decode($schema), new Context($this->refProvider));
      return Schema::import($this->decodeJson($schema), $context);
    }
    catch (InvalidValue $invalidValueException) {
      // Wrap the exception in our own class for abstraction.
@@ -78,4 +125,31 @@ class SchemaFactory {
    }
  }

  /**
   * Get a configured default context for use in schema operations.
   *
   * @return \Swaggest\JsonSchema\Context
   *   A configured default context for schema operations.
   */
  public function getDefaultContext(): Context {
    // Clone the default context to avoid capturing processing state data during
    // usage of the returned instance in schema operations.
    return clone($this->defaultContext);
  }

  /**
   * Encapsulate the process for decoding provided JSON values.
   *
   * @param string $schema
   *   The JSON string value to be decoded.
   *
   * @return mixed
   *   The decoded JSON value.
   *
   * @throws \JsonException
   */
  protected function decodeJson(string $schema) {
    return json_decode($schema, FALSE, 512, JSON_THROW_ON_ERROR);
  }

}
+9 −2
Original line number Diff line number Diff line
@@ -31,7 +31,10 @@ class SchemaHelper {
   */
  public static function isCompositionSchema(SchemaContract $schema): bool {
    foreach (['anyOf', 'oneOf', 'allOf'] as $constraint) {
      if (isset($schema->$constraint)) {
      // Test like this instead of using isset() or empty() to avoid triggering
      // an exception if we're working with a Wrapper instance.
      // @see \Swaggest\JsonSchema\Wrapper::__isset
      if (property_exists($schema, $constraint) && $schema->$constraint !== NULL) {
        return TRUE;
      }
    }
@@ -71,7 +74,11 @@ class SchemaHelper {
    $value = is_array($value) && !array_is_list($value) ? static::castArrayToObject($value) : $value;

    try {
      $validationResult = $schema->in($value);
      // Get a globally configured execution context for use in schema
      // operations. This will ensure expected options as well as configured
      // remote reference providers and data preprocessors are available.
      $context = \Drupal::service('patternkit.schema.schema_factory')->getDefaultContext();
      $validationResult = $schema->in($value, $context);
    }
    catch (InvalidValue $exception) {
      // Set the parent document path for context on the exception.
+29 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\patternkit\Traits;

/**
 * A helper trait to consistently decode JSON strings throughout test classes.
 *
 * This trait is only intended for use in tests.
 */
trait JsonDecodeTrait {

  /**
   * Encapsulate the process for decoding provided JSON values.
   *
   * @param string $schema
   *   The JSON string value to be decoded.
   * @param bool $associative
   *   Whether to cast JSON objects as arrays when decoding.
   *
   * @return mixed
   *   The decoded JSON value.
   *
   * @throws \JsonException
   */
  protected function decodeJson(string $schema, bool $associative = FALSE) {
    return json_decode($schema, $associative, 512, JSON_THROW_ON_ERROR);
  }

}
+139 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\patternkit\Traits;

use Drupal\patternkit\Schema\DataPreProcessor\ObjectCoercionDataPreProcessor;
use Drupal\patternkit\Schema\SchemaFactory;
use Drupal\Tests\patternkit\Unit\Schema\TestPatternkitRefProvider;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Container\ContainerInterface;
use Swaggest\JsonSchema\Context;
use Swaggest\JsonSchema\DataPreProcessor;
use Swaggest\JsonSchema\RemoteRefProvider;

/**
 * A helper trait to assist unit tests with dependencies on SchemaHelper.
 *
 * This trait is only intended for use in tests. Implementation of the
 * tearDown() method assumes the implementing class inherits from
 * \PHPUnit\Framework\TestCase for a parent tearDown() implementation to extend.
 *
 * @see \Drupal\patternkit\Schema\SchemaHelper
 */
trait SchemaHelperTestTrait {

  use ProphecyTrait;

  /**
   * A prophecy mock for the schema factory service.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy
   */
  protected ObjectProphecy $schemaFactory;

  /**
   * A reference provider for loading schemas from.
   *
   * @var \Drupal\Tests\patternkit\Unit\Schema\TestPatternkitRefProvider
   */
  protected TestPatternkitRefProvider $refProvider;

  /**
   * A data preprocessor to configure in schema contexts.
   *
   * @var \Swaggest\JsonSchema\DataPreProcessor
   */
  protected DataPreProcessor $dataPreProcessor;

  /**
   * A default schema context to use for schema operations.
   *
   * @var \Swaggest\JsonSchema\Context
   */
  protected Context $context;

  /**
   * Prepares a mocked schema factory service.
   *
   * Prepares a mocked schema factory service with configuration to return a
   * configured schema context from the getDefaultContext() method. If a
   * container is provided as an argument, the mock will be registered as the
   * 'patternkit.schema.schema_factory' service as well.
   *
   * Note: If the container is provided to this method, the mock wil be
   * instantiated and registered, so further manipulation of it in the class
   * variable will not be reflected in the registered service. If further
   * customization and mocking of the service is needed, it is recommended to
   * not provide the container as an argument and register it separately.
   *
   * @param \Psr\Container\ContainerInterface|null $container
   *   (Optional) A container to register the mocked service to.
   *
   * @return \Prophecy\Prophecy\ObjectProphecy
   *   The configured prophecy for the schema factory service.
   */
  protected function setUpSchemaFactory(?ContainerInterface $container = NULL): ObjectProphecy {
    $this->schemaFactory = $this->prophesize(SchemaFactory::class);
    $this->schemaFactory->getDefaultContext()->willReturn($this->getSchemaContext());

    if (isset($container)) {
      $container->set('patternkit.schema.schema_factory', $this->schemaFactory->reveal());
    }

    return $this->schemaFactory;
  }

  /**
   * Get the default remote reference provider for schema contexts.
   *
   * @return \Swaggest\JsonSchema\RemoteRefProvider
   *   A remote reference provider for schema contexts.
   */
  protected function refProvider(): RemoteRefProvider {
    if (!isset($this->refProvider)) {
      $this->refProvider = new TestPatternkitRefProvider();
    }

    return $this->refProvider;
  }

  /**
   * Get the default data preprocessor for schema contexts.
   *
   * @return \Swaggest\JsonSchema\DataPreProcessor
   *   A date preprocessor to use in schema contexts.
   */
  protected function dataPreProcessor(): DataPreProcessor {
    if (!isset($this->dataPreProcessor)) {
      $this->dataPreProcessor = new ObjectCoercionDataPreProcessor();
    }

    return $this->dataPreProcessor;
  }

  /**
   * Get a configured schema context for schema operations.
   *
   * @return \Swaggest\JsonSchema\Context
   *   A configured schema context for schema operations.
   */
  protected function getSchemaContext(): Context {
    if (!isset($this->context)) {
      $this->context = new Context($this->refProvider());
      $this->context->setDataPreProcessor($this->dataPreProcessor());
    }

    // Clone the context to avoid persisting state of schema operations.
    return clone($this->context);
  }

  /**
   * {@inheritdoc}
   */
  public function tearDown(): void {
    parent::tearDown();
    $this->tearDownProphecy();
  }

}
Loading