diff --git a/composer.json b/composer.json index efa6f993b..216152820 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/packages/graphql/README.md b/packages/graphql/README.md index 1d8d1e6ea..f993c1cc8 100644 --- a/packages/graphql/README.md +++ b/packages/graphql/README.md @@ -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](). + +[//]: # (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): diff --git a/packages/graphql/composer.json b/packages/graphql/composer.json index bc42e1336..c744f91b2 100644 --- a/packages/graphql/composer.json +++ b/packages/graphql/composer.json @@ -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": "*", diff --git a/packages/graphql/docs/Scalars/JsonString.md b/packages/graphql/docs/Scalars/JsonString.md new file mode 100644 index 000000000..af4c2dd35 --- /dev/null +++ b/packages/graphql/docs/Scalars/JsonString.md @@ -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) diff --git a/packages/graphql/src/Scalars/JsonString.php b/packages/graphql/src/Scalars/JsonString.php new file mode 100644 index 000000000..e08125ac2 --- /dev/null +++ b/packages/graphql/src/Scalars/JsonString.php @@ -0,0 +1,96 @@ + + // ========================================================================= + 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 $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; + } + // + + // + // ========================================================================= + 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; + } + // +} diff --git a/packages/graphql/src/Scalars/JsonStringTest.php b/packages/graphql/src/Scalars/JsonStringTest.php new file mode 100644 index 000000000..7d83da9ef --- /dev/null +++ b/packages/graphql/src/Scalars/JsonStringTest.php @@ -0,0 +1,147 @@ + + // ========================================================================= + /** + * @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); + } + // + + // + // ========================================================================= + /** + * @return array + */ + 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 + */ + 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 + */ + 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}}']), + ], + ]; + } + // +} diff --git a/packages/graphql/src/Scalars/JsonStringable.php b/packages/graphql/src/Scalars/JsonStringable.php new file mode 100644 index 000000000..43e7e564a --- /dev/null +++ b/packages/graphql/src/Scalars/JsonStringable.php @@ -0,0 +1,13 @@ +