Skip to content

Commit

Permalink
feat(graphql): JsonString scalar.
Browse files Browse the repository at this point in the history
  • Loading branch information
LastDragon-ru committed Sep 15, 2023
2 parents 1e4e213 + a54cdd3 commit 06ad9be
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 1 deletion.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"symfony/finder": "^6.3.0",
"symfony/http-foundation": "^6.3.0",
"symfony/mime": "^6.3.0",
"symfony/polyfill-php83": "^1.28",
"symfony/process": "^6.3.0",
"symfony/property-access": "^6.3.0",
"symfony/property-info": "^6.3.0",
Expand Down
25 changes: 25 additions & 0 deletions packages/graphql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,31 @@ Probably the most powerful directive to provide sort (`order by` conditions) for

[//]: # (end: ac98e04e18d99ce0a6af07947adce086ad2450bda152abe31548ebe09831ec9a)

# Scalars

> [!IMPORTANT]
>
> You should register the Scalar before use, it can be done via [`AstManipulator`](./src/Utils/AstManipulator.php) (useful while AST manipulation), [`TypeRegistry`](https://lighthouse-php.com/master/digging-deeper/adding-types-programmatically.html#using-the-typeregistry), or as a custom scalar inside the Schema:
>
> ```graphql
> scalar JsonString
> @scalar(
> class: "LastDragon_ru\\LaraASP\\GraphQL\\Scalars\\JsonString"
> )
> ```
[include:document-list]: ./docs/Scalars
[//]: # (start: e3795f388ca164b6568d7e4b8d642c7a6ad049711bb0777e6b09e9b5b19e1e11)
[//]: # (warning: Generated automatically. Do not edit.)
## `JsonString`
Represents [JSON](https://json.org) string.
[Read more](<docs/Scalars/JsonString.md>).
[//]: # (end: e3795f388ca164b6568d7e4b8d642c7a6ad049711bb0777e6b09e9b5b19e1e11)
# Scout
[Scout](https://laravel.com/docs/scout) is also supported 🤩. By default `@searchBy`/`@sortBy` will convert nested/related properties into dot string: eg `{user: {name: asc}}` will be converted into `user.name`. You can redefine this behavior by [`FieldResolver`](./src/Builder/Contracts/Scout/FieldResolver.php):
Expand Down
3 changes: 2 additions & 1 deletion packages/graphql/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"nuwave/lighthouse": "^6.5.0",
"lastdragon-ru/lara-asp-core": "self.version",
"lastdragon-ru/lara-asp-eloquent": "self.version",
"lastdragon-ru/lara-asp-graphql-printer": "self.version"
"lastdragon-ru/lara-asp-graphql-printer": "self.version",
"symfony/polyfill-php83": "^1.28"
},
"require-dev": {
"ext-pdo_sqlite": "*",
Expand Down
13 changes: 13 additions & 0 deletions packages/graphql/docs/Scalars/JsonString.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# `JsonString`

Represents [JSON](https://json.org) string.

Please note that the scalar doesn't encode/decode value to/from JSON, it just contains a valid JSON string. If you want automatically convert value to/from JSON, you can use the `JSON` type from [`mll-lab/graphql-php-scalars`](https://github.com/mll-lab/graphql-php-scalars) package. If you need something more typesafe, consider using [`Serializer`][pkg:serializer].

[include:file]: ../../../../docs/shared/Links.md
[//]: # (start: a170145c7adc0561ead408b0ea3a4b46e2e8f45ebc2744984ceb8c1b49822cd1)
[//]: # (warning: Generated automatically. Do not edit.)

[pkg:serializer]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/serializer

[//]: # (end: a170145c7adc0561ead408b0ea3a4b46e2e8f45ebc2744984ceb8c1b49822cd1)
96 changes: 96 additions & 0 deletions packages/graphql/src/Scalars/JsonString.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php declare(strict_types = 1);

namespace LastDragon_ru\LaraASP\GraphQL\Scalars;

use Exception;
use GraphQL\Error\Error;
use GraphQL\Error\InvariantViolation;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\NodeKind;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Language\AST\TypeDefinitionNode;
use GraphQL\Type\Definition\StringType;
use GraphQL\Type\Definition\Type;
use GraphQL\Utils\Utils;
use LastDragon_ru\LaraASP\GraphQL\Builder\BuilderInfo;
use LastDragon_ru\LaraASP\GraphQL\Builder\Contracts\TypeDefinition;
use LastDragon_ru\LaraASP\GraphQL\Builder\Contracts\TypeSource;
use LastDragon_ru\LaraASP\GraphQL\Builder\Manipulator;

use function is_string;
use function json_validate;
use function sprintf;

class JsonString extends StringType implements TypeDefinition {
public string $name = 'JsonString';
public ?string $description = 'Represents JSON string.';

// <editor-fold desc="ScalarType">
// =========================================================================
public function serialize(mixed $value): string {
if ($value instanceof JsonStringable) {
$value = (string) $value;
} else {
$value = $this->validate($value, InvariantViolation::class);
}

return $value;
}

public function parseValue(mixed $value): string {
return $this->validate($value, Error::class);
}

/**
* @inheritDoc
*/
public function parseLiteral(Node $valueNode, array $variables = null): string {
if (!($valueNode instanceof StringValueNode)) {
throw new Error(
sprintf(
'The `%s` value expected, `%s` given.',
NodeKind::STRING,
$valueNode->kind,
),
);
}

return $this->parseValue($valueNode->value);
}

/**
* @param class-string<Exception> $error
*
* @phpstan-assert string $value
*/
protected function validate(mixed $value, string $error): string {
if (is_string($value) && json_validate($value)) {
// ok
} else {
throw new $error(
sprintf(
'The valid JSON string expected, `%s` given.',
Utils::printSafe($value),
),
);
}

return $value;
}
// </editor-fold>

// <editor-fold desc="TypeDefinition">
// =========================================================================
public function getTypeName(Manipulator $manipulator, BuilderInfo $builder, TypeSource $source): string {
return $this->name();
}

public function getTypeDefinition(
Manipulator $manipulator,
string $name,
TypeSource $source,
): TypeDefinitionNode|Type|null {
return $this;
}
// </editor-fold>
}
147 changes: 147 additions & 0 deletions packages/graphql/src/Scalars/JsonStringTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php declare(strict_types = 1);

namespace LastDragon_ru\LaraASP\GraphQL\Scalars;

use Exception;
use GraphQL\Error\Error;
use GraphQL\Error\InvariantViolation;
use GraphQL\Language\AST\IntValueNode;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Language\AST\ValueNode;
use LastDragon_ru\LaraASP\GraphQL\Testing\Package\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;

/**
* @internal
*/
#[CoversClass(JsonString::class)]
class JsonStringTest extends TestCase {
// <editor-fold desc="Tests">
// =========================================================================
/**
* @dataProvider dataProviderSerialize
*/
public function testSerialize(?Exception $expected, mixed $value): void {
if ($expected instanceof Exception) {
self::expectExceptionObject($expected);
}

$scalar = new JsonString();
$actual = $scalar->serialize($value);

if ($value instanceof JsonStringable) {
self::assertEquals((string) $value, $actual);
} else {
self::assertEquals($value, $actual);
}
}

/**
* @dataProvider dataProviderParseValue
*/
public function testParseValue(?Exception $expected, mixed $value): void {
if ($expected instanceof Exception) {
self::expectExceptionObject($expected);
}

$scalar = new JsonString();
$actual = $scalar->parseValue($value);

self::assertIsString($value);
self::assertEquals($value, $actual);
}

/**
* @dataProvider dataProviderParseLiteral
*/
public function testParseLiteral(?Exception $expected, Node&ValueNode $value): void {
if ($expected instanceof Exception) {
self::expectExceptionObject($expected);
}

$scalar = new JsonString();
$actual = $scalar->parseLiteral($value);

self::assertInstanceOf(StringValueNode::class, $value);
self::assertEquals($value->value, $actual);
}
// </editor-fold>

// <editor-fold desc="DataProviders">
// =========================================================================
/**
* @return array<string, array{?Exception, mixed}>
*/
public static function dataProviderSerialize(): array {
return [
'not a string' => [
new InvariantViolation('The valid JSON string expected, `123` given.'),
123,
],
'string but not a valid json' => [
new InvariantViolation('The valid JSON string expected, `"invalid json"` given.'),
'invalid json',
],
'string and a valid json' => [
null,
'{"a": 123, "b": {"c": 45}}',
],
JsonStringable::class => [
null,
new class('{"a": 123, "b": {"c": 45}}') implements JsonStringable {
public function __construct(
private readonly string $json,
) {
// empty
}

public function __toString(): string {
return $this->json;
}
},
],
];
}

/**
* @return array<string, array{?Exception, mixed}>
*/
public static function dataProviderParseValue(): array {
return [
'not a string' => [
new Error('The valid JSON string expected, `123` given.'),
123,
],
'string but not a valid json' => [
new Error('The valid JSON string expected, `"invalid json"` given.'),
'invalid json',
],
'string and a valid json' => [
null,
'{"a": 123, "b": {"c": 45}}',
],
];
}

/**
* @return array<string, array{?Exception, Node&ValueNode}>
*/
public static function dataProviderParseLiteral(): array {
return [
'not a string' => [
new Error('The `StringValue` value expected, `IntValue` given.'),
new IntValueNode(['value' => '123']),
],
'string but not a valid json' => [
new Error('The valid JSON string expected, `"invalid json"` given.'),
new StringValueNode(['value' => 'invalid json']),
],
'string and a valid json' => [
null,
new StringValueNode(['value' => '{"a": 123, "b": {"c": 45}}']),
],
];
}
// </editor-fold>
}
13 changes: 13 additions & 0 deletions packages/graphql/src/Scalars/JsonStringable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace LastDragon_ru\LaraASP\GraphQL\Scalars;

use Stringable;

/**
* Marks that string representation of the class is already a valid JSON string,
* so validation can be omitted.
*/
interface JsonStringable extends Stringable {
// empty
}

0 comments on commit 06ad9be

Please sign in to comment.