Skip to content

Commit

Permalink
JsonSchema: Allow access by path and as array
Browse files Browse the repository at this point in the history
  • Loading branch information
Dominic Tubach committed Jan 15, 2024
1 parent 46ea30d commit 57e9958
Show file tree
Hide file tree
Showing 2 changed files with 179 additions and 9 deletions.
126 changes: 117 additions & 9 deletions Civi/RemoteTools/JsonSchema/JsonSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,24 @@

namespace Civi\RemoteTools\JsonSchema;

class JsonSchema implements \JsonSerializable {
use Webmozart\Assert\Assert;

/**
* @phpstan-type TValue scalar|self|null|list<scalar|self|null>
*
* @implements \ArrayAccess<string, TValue>
*/
class JsonSchema implements \ArrayAccess, \JsonSerializable {

/**
* @var array<string, scalar|self|null|array<int, scalar|self|null>>
* @var array<string, TValue>
*/
protected array $keywords;

/**
* @param array<int, mixed> $array
* @param list<mixed> $array
*
* @return array<int, scalar|self|null>
* @return list<scalar|self|null>
*/
public static function convertToJsonSchemaArray(array $array): array {
return \array_values(\array_map(function ($value) {
Expand Down Expand Up @@ -69,7 +76,7 @@ public static function fromArray(array $array): self {
}
}

/** @var array<string, scalar|self|null|array<int, scalar|self|null>> $array */
/** @var array<string, TValue> $array */
return new self($array);
}

Expand Down Expand Up @@ -111,15 +118,15 @@ protected static function isAllowedValue($value): bool {
}

/**
* @param array<string, scalar|self|null|array<int, scalar|self|null>> $keywords
* @param array<string, TValue> $keywords
*/
public function __construct(array $keywords) {
$this->keywords = $keywords;
}

/**
* @param string $keyword
* @param scalar|self|null|array<int, scalar|self|null> $value
* @param TValue $value
*
* @return $this
*/
Expand All @@ -134,7 +141,7 @@ public function addKeyword(string $keyword, $value): self {
}

/**
* @return array<string, scalar|self|null|array<int, scalar|self|null>>
* @return array<string, TValue>
*/
public function getKeywords(): array {
return $this->keywords;
Expand All @@ -147,7 +154,7 @@ public function hasKeyword(string $keyword): bool {
/**
* @param string $keyword
*
* @return scalar|self|null|array<int, scalar|self|null>
* @return TValue
*/
public function getKeywordValue(string $keyword) {
if (!$this->hasKeyword($keyword)) {
Expand All @@ -166,6 +173,57 @@ public function getKeywordValueOrDefault(string $keyword, $default) {
return $this->hasKeyword($keyword) ? $this->keywords[$keyword] : $default;
}

/**
* @phpstan-param string|array<string> $path
*
* @return TValue
*/
public function getKeywordValueAt($path) {
if (is_string($path)) {
$path = explode('/', ltrim($path, '/'));
}
else {
Assert::isArray($path);
}

$keywordValue = $this;
foreach ($path as $pathElement) {
if (!$keywordValue instanceof JsonSchema || !$keywordValue->hasKeyword($pathElement)) {
throw new \InvalidArgumentException(\sprintf('No keyword at "%s"', implode('/', $path)));
}

$keywordValue = $keywordValue->getKeywordValue($pathElement);
}

return $keywordValue;
}

/**
* @phpstan-param string|array<string> $path
* @param mixed $default
*
* @return mixed
*/
public function getKeywordValueAtOrDefault($path, $default) {
if (is_string($path)) {
$path = explode('/', ltrim($path, '/'));
}
else {
Assert::isArray($path);
}

$keywordValue = $this;
foreach ($path as $pathElement) {
if (!$keywordValue instanceof JsonSchema || !$keywordValue->hasKeyword($pathElement)) {
return $default;
}

$keywordValue = $keywordValue->getKeywordValue($pathElement);
}

return $keywordValue;
}

/**
* @return array<string, mixed> Values are of type array|scalar|null with leaves of type array{}|scalar|null.
*/
Expand Down Expand Up @@ -208,4 +266,54 @@ public function jsonSerialize() {
return $this->toArray();
}

/**
* @inheritDoc
*/
public function offsetExists($keyword): bool {
return $this->hasKeyword($keyword);
}

/**
* @inheritDoc
*/
public function offsetGet($keyword) {
return $this->keywords[$keyword] ?? NULL;
}

/**
* @inheritDoc
*
* @param scalar|self|null|list<mixed>|array<string, mixed> $value
* Array values can be scalars, NULL, or JsonSchema objects, and arrays
* containing values of these three types.
*/
public function offsetSet($keyword, $value): void {
if (!is_string($keyword)) {
throw new \InvalidArgumentException(sprintf('Offset must be of type string, got %s', gettype($keyword)));
}

if (\is_array($value)) {
if (\is_string(key($value))) {
// @phpstan-ignore-next-line
$value = self::fromArray($value);
}
else {
// @phpstan-ignore-next-line
$value = self::convertToJsonSchemaArray($value);
}
}
else {
static::assertAllowedValue($value);
}

$this->keywords[$keyword] = $value;
}

/**
* @inheritDoc
*/
public function offsetUnset($keyword): void {
unset($this->keywords[$keyword]);
}

}
62 changes: 62 additions & 0 deletions tests/phpunit/Civi/RemoteTools/JsonSchema/JsonSchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,46 @@ public function testAddKeyword(): void {
$schema->addKeyword('foo', 'bar2');
}

public function testGetKeywordValueAt(): void {
$schemaB = new JsonSchema([
'c' => 'd',
]);

$schema = new JsonSchema([
'a' => new JsonSchema([
'b' => $schemaB,
]),
]);

static::assertSame('d', $schema->getKeywordValueAt('a/b/c'));
static::assertSame('d', $schema->getKeywordValueAt('/a/b/c'));
static::assertSame('d', $schema->getKeywordValueAt(['a', 'b', 'c']));
static::assertSame($schemaB, $schema->getKeywordValueAt('a/b'));

static::expectException(\InvalidArgumentException::class);
static::expectExceptionMessage('No keyword at "a/c"');
$schema->getKeywordValueAt('/a/c');
}

public function testGetKeywordValueAtOrDefault(): void {
$schemaB = new JsonSchema([
'c' => 'd',
]);

$schema = new JsonSchema([
'a' => new JsonSchema([
'b' => $schemaB,
]),
]);

static::assertSame('d', $schema->getKeywordValueAtOrDefault('a/b/c', 'X'));
static::assertSame('d', $schema->getKeywordValueAtOrDefault('/a/b/c', 'X'));
static::assertSame('d', $schema->getKeywordValueAtOrDefault(['a', 'b', 'c'], 'X'));
static::assertSame($schemaB, $schema->getKeywordValueAtOrDefault('a/b', 'X'));

static::assertSame('X', $schema->getKeywordValueAtOrDefault('/a/c', 'X'));
}

public function testGetMissingKeyword(): void {
$schema = new JsonSchema([]);
static::expectException(\InvalidArgumentException::class);
Expand Down Expand Up @@ -115,4 +155,26 @@ public function testConvertToJsonSchemaArrayInvalid(): void {
JsonSchema::convertToJsonSchemaArray([['invalid']]);
}

public function testArrayAccess(): void {
$schema = new JsonSchema([]);

static::assertArrayNotHasKey('test', $schema);
static::assertNull($schema['test']);

$schema['test'] = 'x';
static::assertArrayHasKey('test', $schema);
static::assertSame('x', $schema['test']);

$schema['test'] = ['x', 'y'];
static::assertSame(['x', 'y'], $schema['test']);

$test = ['x' => 'y', 'y' => new JsonSchema([])];
$schema['test'] = $test;
static::assertEquals(JsonSchema::fromArray($test), $schema['test']);

$schema['test'] = NULL;
static::assertArrayHasKey('test', $schema);
static::assertNull($schema['test']);
}

}

0 comments on commit 57e9958

Please sign in to comment.